From dbe4bedeb9f5c6217e0a1297cf356ba89f9ece06 Mon Sep 17 00:00:00 2001 From: Balaji Siva Date: Mon, 16 Feb 2026 11:23:00 -0800 Subject: [PATCH 001/118] Add Template Builder plugin to directory Visual editor for creating and managing Backstage scaffolder templates with live YAML preview, flow visualization, and GitHub integration. Package: @balajisiva/backstage-plugin-template-builder Signed-off-by: Balaji Siva --- .../balajisiva-backstage-plugin-template-builder.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 microsite/data/plugins/balajisiva-backstage-plugin-template-builder.yaml diff --git a/microsite/data/plugins/balajisiva-backstage-plugin-template-builder.yaml b/microsite/data/plugins/balajisiva-backstage-plugin-template-builder.yaml new file mode 100644 index 0000000000..91ea3e5c6e --- /dev/null +++ b/microsite/data/plugins/balajisiva-backstage-plugin-template-builder.yaml @@ -0,0 +1,10 @@ +--- +title: Template Builder +author: Balaji Sivasubramanian +authorUrl: https://github.com/balajisiva +category: Tooling +description: Visual editor for creating and managing Backstage scaffolder templates with live YAML preview, flow visualization, and GitHub integration +documentation: https://github.com/balajisiva/backstage-template-builder#readme +iconUrl: https://raw.githubusercontent.com/balajisiva/backstage-template-builder/main/plugins/backstage-template-builder/icon.svg +npmPackageName: '@balajisiva/backstage-plugin-template-builder' +addedDate: '2026-02-16' From e5ee4d8da7f8315df8254274d12eed2eeedd75ba Mon Sep 17 00:00:00 2001 From: Nilay1999 Date: Mon, 12 Jan 2026 11:30:26 +0530 Subject: [PATCH 002/118] feat(catalog-backend): :sparkles: add predicate-based entity filtering - Add EntityPredicate and EntityPredicateRequest types to catalog types - Implement queryEntitiesByPredicate method in DefaultEntitiesCatalog for predicate-based filtering - Create applyPredicateEntityFilterToQuery utility to convert predicates to database queries Signed-off-by: Nilay1999 --- plugins/catalog-backend/src/catalog/types.ts | 18 +- .../src/service/DefaultEntitiesCatalog.ts | 89 +++++++ .../applyPredicateEntityFilterToQuery.ts | 239 ++++++++++++++++++ plugins/catalog-node/src/api/common.ts | 66 +++++ plugins/catalog-node/src/api/index.ts | 4 + 5 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 1fbf337986..1b87352318 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -16,7 +16,7 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { EntityFilter } from '@backstage/plugin-catalog-node'; +import { EntityFilter, EntityPredicate } from '@backstage/plugin-catalog-node'; /** * A pagination rule for entities. @@ -52,6 +52,13 @@ export type EntitiesRequest = { credentials: BackstageCredentials; }; +export type EntityPredicateRequest = { + filter?: EntityPredicate; + order?: EntityOrder[]; + pagination?: EntityPagination; + credentials: BackstageCredentials; +}; + /** * Encapsulates either a deserialized or serialized entities to be sent in a response. * @internal @@ -165,6 +172,15 @@ export interface EntitiesCatalog { */ queryEntities(request: QueryEntitiesRequest): Promise; + /** + * Fetch entities using predicate-based filters. + * + * @param request - Request options with predicate filter + */ + queryEntitiesByPredicate( + request?: EntityPredicateRequest, + ): Promise; + /** * Removes a single entity. * diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 92c0bb0ecf..c7b14282ef 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -31,6 +31,7 @@ import { EntityFacetsResponse, EntityOrder, EntityPagination, + EntityPredicateRequest, QueryEntitiesRequest, QueryEntitiesResponse, } from '../catalog/types'; @@ -52,6 +53,7 @@ import { import { EntityFilter } from '@backstage/plugin-catalog-node'; import { LoggerService } from '@backstage/backend-plugin-api'; import { applyEntityFilterToQuery } from './request/applyEntityFilterToQuery'; +import { applyPredicateEntityFilterToQuery } from './request/applyPredicateEntityFilterToQuery'; import { processRawEntitiesResult } from './response'; const DEFAULT_LIMIT = 200; @@ -213,6 +215,93 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { }; } + async queryEntitiesByPredicate( + request?: EntityPredicateRequest, + ): Promise { + const db = this.database; + const { limit, offset } = parsePagination(request?.pagination); + + let entitiesQuery = + db('final_entities').select('final_entities.*'); + + request?.order?.forEach(({ field }, index) => { + const alias = `order_${index}`; + entitiesQuery = entitiesQuery.leftOuterJoin( + { [alias]: 'search' }, + function search(inner) { + inner + .on(`${alias}.entity_id`, 'final_entities.entity_id') + .andOn(`${alias}.key`, db.raw('?', [field])); + }, + ); + }); + + entitiesQuery = entitiesQuery.whereNotNull('final_entities.final_entity'); + + if (request?.filter) { + entitiesQuery = applyPredicateEntityFilterToQuery({ + filter: request.filter, + targetQuery: entitiesQuery, + onEntityIdField: 'final_entities.entity_id', + knex: db, + }); + } + + request?.order?.forEach(({ order }, index) => { + if (db.client.config.client === 'pg') { + entitiesQuery = entitiesQuery.orderBy([ + { column: `order_${index}.value`, order, nulls: 'last' }, + ]); + } else { + entitiesQuery = entitiesQuery.orderBy([ + { column: `order_${index}.value`, order: undefined, nulls: 'last' }, + { column: `order_${index}.value`, order }, + ]); + } + }); + + if (!request?.order) { + entitiesQuery = entitiesQuery.orderBy('final_entities.entity_ref', 'asc'); + } else { + entitiesQuery.orderBy('final_entities.entity_id', 'asc'); + } + + if (limit !== undefined) { + entitiesQuery = entitiesQuery.limit(limit + 1); + } + if (offset !== undefined) { + entitiesQuery = entitiesQuery.offset(offset); + } + + let rows = await entitiesQuery; + let pageInfo: DbPageInfo; + if (limit === undefined || rows.length <= limit) { + pageInfo = { hasNextPage: false }; + } else { + rows = rows.slice(0, -1); + pageInfo = { + hasNextPage: true, + endCursor: stringifyPagination({ + limit, + offset: (offset ?? 0) + limit, + }), + }; + } + + return { + entities: processRawEntitiesResult( + rows.map(r => r.final_entity!), + this.enableRelationsCompatibility + ? e => { + expandLegacyCompoundRelationsInEntity(e); + return e; + } + : undefined, + ), + pageInfo, + }; + } + async entitiesBatch( request: EntitiesBatchRequest, ): Promise { diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts new file mode 100644 index 0000000000..59e08e7d9e --- /dev/null +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -0,0 +1,239 @@ +/* + * Copyright 2024 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 { + EntityPredicate, + EntityPredicatePrimitive, + EntityPredicateValue, +} from '@backstage/plugin-catalog-node'; +import { Knex } from 'knex'; +import { DbSearchRow } from '../../database/tables'; + +function isAllPredicate( + filter: EntityPredicate, +): filter is { $all: EntityPredicate[] } { + return typeof filter === 'object' && filter !== null && '$all' in filter; +} + +function isAnyPredicate( + filter: EntityPredicate, +): filter is { $any: EntityPredicate[] } { + return typeof filter === 'object' && filter !== null && '$any' in filter; +} + +function isNotPredicate( + filter: EntityPredicate, +): filter is { $not: EntityPredicate } { + return typeof filter === 'object' && filter !== null && '$not' in filter; +} + +function isPrimitive(value: unknown): value is EntityPredicatePrimitive { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ); +} + +function isExistsValue( + value: EntityPredicateValue, +): value is { $exists: boolean } { + return typeof value === 'object' && value !== null && '$exists' in value; +} + +function isInValue( + value: EntityPredicateValue, +): value is { $in: EntityPredicatePrimitive[] } { + return typeof value === 'object' && value !== null && '$in' in value; +} + +function isFieldExpression(filter: EntityPredicate): boolean { + if (typeof filter !== 'object' || filter === null) { + return false; + } + // Not a logical operator + return !('$all' in filter || '$any' in filter || '$not' in filter); +} + +/** + * example generated query + * Selects all non-null final entities that: + * + * - Are of kind "component" + * - Have spec.type = "service" + * - Are owned by either: + * - backend-team + * - platform-team + * - Are NOT in experimental lifecycle + * + * Results are ordered by entity_ref in ascending order. + * + * SQL Reference: + * ``` + * SELECT final_entities.* + * FROM final_entities + * WHERE final_entities.final_entity IS NOT NULL + * AND final_entities.entity_id IN ( + * SELECT entity_id FROM search WHERE key = 'kind' AND value = 'component' + * ) + * AND final_entities.entity_id IN ( + * SELECT entity_id FROM search WHERE key = 'spec.type' AND value = 'service' + * ) + * AND ( + * final_entities.entity_id IN ( + * SELECT entity_id FROM search WHERE key = 'spec.owner' AND value = 'backend-team' + * ) + * OR + * final_entities.entity_id IN ( + * SELECT entity_id FROM search WHERE key = 'spec.owner' AND value = 'platform-team' + * ) + * ) + * AND final_entities.entity_id NOT IN ( + * SELECT entity_id FROM search WHERE key = 'spec.lifecycle' AND value = 'experimental' + * ) + * ORDER BY final_entities.entity_ref ASC; + * ``` + */ + +function applyPredicateInStrategy( + filter: EntityPredicate, + targetQuery: Knex.QueryBuilder, + onEntityIdField: string, + knex: Knex, + negate: boolean, +): Knex.QueryBuilder { + // Handle $not + if (isNotPredicate(filter)) { + return applyPredicateInStrategy( + filter.$not, + targetQuery, + onEntityIdField, + knex, + !negate, + ); + } + + // Handle $all (AND) + if (isAllPredicate(filter)) { + return targetQuery[negate ? 'andWhereNot' : 'andWhere']( + function allFilter() { + for (const subFilter of filter.$all) { + this.andWhere(subQuery => + applyPredicateInStrategy( + subFilter, + subQuery, + onEntityIdField, + knex, + false, + ), + ); + } + }, + ); + } + + // Handle $any (OR) + if (isAnyPredicate(filter)) { + return targetQuery[negate ? 'andWhereNot' : 'andWhere']( + function anyFilter() { + for (const subFilter of filter.$any) { + this.orWhere(subQuery => + applyPredicateInStrategy( + subFilter, + subQuery, + onEntityIdField, + knex, + false, + ), + ); + } + }, + ); + } + + // Handle primitive value at top level (e.g., "component" shorthand) + if (isPrimitive(filter)) { + const matchQuery = knex('search') + .select('search.entity_id') + .where({ value: String(filter).toLowerCase() }); + return targetQuery.andWhere( + onEntityIdField, + negate ? 'not in' : 'in', + matchQuery, + ); + } + + // Handle field expressions like { "kind": "component" } or { "spec.type": { "$in": ["service", "website"] } } + if (isFieldExpression(filter)) { + return targetQuery[negate ? 'andWhereNot' : 'andWhere']( + function fieldFilter() { + for (const [key, value] of Object.entries(filter)) { + const normalizedKey = key.toLowerCase(); + + if (isExistsValue(value)) { + // Handle $exists + const existsQuery = knex('search') + .select('search.entity_id') + .where({ key: normalizedKey }); + + if (value.$exists) { + this.andWhere(onEntityIdField, 'in', existsQuery); + } else { + this.andWhere(onEntityIdField, 'not in', existsQuery); + } + } else if (isInValue(value)) { + // Handle $in + const values = value.$in.map(v => String(v).toLowerCase()); + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key: normalizedKey }) + .whereIn('value', values); + this.andWhere(onEntityIdField, 'in', matchQuery); + } else if (isPrimitive(value)) { + // Handle direct value match + const matchQuery = knex('search') + .select('search.entity_id') + .where({ + key: normalizedKey, + value: String(value).toLowerCase(), + }); + this.andWhere(onEntityIdField, 'in', matchQuery); + } + } + }, + ); + } + + return targetQuery; +} + +export function applyPredicateEntityFilterToQuery(options: { + filter: EntityPredicate; + targetQuery: Knex.QueryBuilder; + onEntityIdField: string; + knex: Knex; + strategy?: 'in' | 'join'; +}): Knex.QueryBuilder { + const { filter, targetQuery, onEntityIdField, knex } = options; + + return applyPredicateInStrategy( + filter, + targetQuery, + onEntityIdField, + knex, + false, + ); +} diff --git a/plugins/catalog-node/src/api/common.ts b/plugins/catalog-node/src/api/common.ts index 7f9bd3eabf..f88857a312 100644 --- a/plugins/catalog-node/src/api/common.ts +++ b/plugins/catalog-node/src/api/common.ts @@ -85,3 +85,69 @@ export type EntitiesSearchFilter = { */ values?: string[]; }; + +/** + * Defines a predicate used to filter entities using logical and comparison operators. + * + * This type supports: + * - Primitive equality matching + * - Field existence checks + * - Inclusion checks + * - Logical composition using $all, $any, and $not + * + * @public + */ +export type EntityPredicate = + | EntityPredicateExpression + | EntityPredicatePrimitive + | { $all: EntityPredicate[] } + | { $any: EntityPredicate[] } + | { $not: EntityPredicate }; + +/** + * Represents a field-based predicate expression. + * + * Each key is treated as a JSON path into the entity object. + * Keys starting with `$` are reserved for operators and are not allowed. + * + * Example: + * ```ts + * { + * "spec.type": "service", + * "metadata.name": { $in: ["payments", "orders"] } + * } + * ``` + * + * @public + */ +export type EntityPredicateExpression = { + /** JSON path of the entity field */ + [KPath in string]: EntityPredicateValue; +} & { + /** Operator keys are not allowed as field paths */ + [KPath in `$${string}`]: never; +}; + +/** + * Represents a value condition for a predicate field. + * + * Supported forms: + * - Primitive equality match + * - Existence check using `$exists` + * - Inclusion check using `$in` + * + * @public + */ +export type EntityPredicateValue = + | EntityPredicatePrimitive + | { $exists: boolean } + | { $in: EntityPredicatePrimitive[] }; + +/** + * Represents a primitive predicate value. + * + * Used for direct equality comparison. + * + * @public + */ +export type EntityPredicatePrimitive = string | number | boolean; diff --git a/plugins/catalog-node/src/api/index.ts b/plugins/catalog-node/src/api/index.ts index 292a6cd872..5b7abbb6b9 100644 --- a/plugins/catalog-node/src/api/index.ts +++ b/plugins/catalog-node/src/api/index.ts @@ -20,6 +20,10 @@ export type { LocationSpec, EntitiesSearchFilter, EntityFilter, + EntityPredicate, + EntityPredicateExpression, + EntityPredicateValue, + EntityPredicatePrimitive, } from './common'; export type { CatalogProcessor, From b883042faccf42b4a7ac3b6298783667b6a9c5cc Mon Sep 17 00:00:00 2001 From: Nilay1999 Date: Mon, 12 Jan 2026 11:32:28 +0530 Subject: [PATCH 003/118] =?UTF-8?q?feat(catalog-backend):=20=E2=9C=A8=20ad?= =?UTF-8?q?d=20post=20api=20for=20predicate-based=20entity=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add new /entities/by-predicates POST endpoint for expressive entity queries - Introduce EntityPredicate schema supporting logical operators ($all, $any, $not) - Add EntityPredicateValue schema with field operators ($exists, $in) - Generate OpenAPI models for predicate-based filtering - Update failing test case files Signed-off-by: Nilay1999 --- .../catalog-backend/src/schema/openapi.yaml | 165 ++++++++++++ .../openapi/generated/apis/Api.server.ts | 17 ++ .../generated/models/EntityPredicate.model.ts | 36 +++ .../models/EntityPredicateOneOf.model.ts | 27 ++ .../models/EntityPredicateOneOf1.model.ts | 27 ++ .../models/EntityPredicateOneOf2.model.ts | 27 ++ .../models/EntityPredicateValue.model.ts | 32 +++ .../models/EntityPredicateValueOneOf.model.ts | 26 ++ .../EntityPredicateValueOneOf1.model.ts | 27 ++ ...EntityPredicateValueOneOf1InInner.model.ts | 24 ++ ...etEntitiesByPredicates200Response.model.ts | 32 +++ ...esByPredicates200ResponsePageInfo.model.ts | 29 ++ .../GetEntitiesByPredicatesRequest.model.ts | 27 ++ .../schema/openapi/generated/models/index.ts | 11 + .../src/schema/openapi/generated/router.ts | 247 ++++++++++++++++++ .../service/AuthorizedEntitiesCatalog.test.ts | 1 + .../src/service/AuthorizedEntitiesCatalog.ts | 31 +++ .../src/service/createRouter.test.ts | 3 + .../src/service/createRouter.ts | 52 +++- 19 files changed, 840 insertions(+), 1 deletion(-) create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicatesRequest.model.ts diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index 9f78cc6210..d4c7560c30 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -338,6 +338,71 @@ components: properties: {} description: A type representing all allowed JSON object values. additionalProperties: {} + EntityPredicate: + description: | + A predicate-based filter supporting logical operators. + - $all: All conditions must match (AND) + - $any: At least one condition must match (OR) + - $not: Negates the condition + - $exists: Check if field exists + - $in: Match any value in array + oneOf: + - type: string + - type: number + - type: boolean + - type: object + additionalProperties: false + properties: + $all: + type: array + items: + $ref: '#/components/schemas/EntityPredicate' + required: + - $all + - type: object + additionalProperties: false + properties: + $any: + type: array + items: + $ref: '#/components/schemas/EntityPredicate' + required: + - $any + - type: object + additionalProperties: false + properties: + $not: + $ref: '#/components/schemas/EntityPredicate' + required: + - $not + - type: object + additionalProperties: + $ref: '#/components/schemas/EntityPredicateValue' + EntityPredicateValue: + description: Value for a field predicate + oneOf: + - type: string + - type: number + - type: boolean + - type: object + additionalProperties: false + properties: + $exists: + type: boolean + required: + - $exists + - type: object + additionalProperties: false + properties: + $in: + type: array + items: + oneOf: + - type: string + - type: number + - type: boolean + required: + - $in MapStringString: type: object properties: {} @@ -1095,6 +1160,106 @@ paths: type: string explode: false style: form + /entities/by-predicates: + post: + operationId: GetEntitiesByPredicates + tags: + - Entity + description: | + Query entities using predicate-based filters. This endpoint supports a more + expressive filter syntax with logical operators ($all, $any, $not) and + value operators ($exists, $in). + + Example filter: + ```json + { + "filter": { + "$all": [ + {"kind": "component"}, + {"$any": [ + {"spec.type": "service"}, + {"spec.type": "website"} + ]}, + {"$not": {"spec.lifecycle": "experimental"}} + ] + } + } + ``` + responses: + '200': + description: Ok + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/Entity' + description: The list of entities matching the predicate filter. + pageInfo: + type: object + properties: + nextCursor: + type: string + description: The cursor for the next batch of entities. + required: + - items + - pageInfo + '400': + $ref: '#/components/responses/ErrorResponse' + default: + $ref: '#/components/responses/ErrorResponse' + security: + - {} + - JWT: [] + parameters: + - $ref: '#/components/parameters/limit' + - $ref: '#/components/parameters/offset' + - $ref: '#/components/parameters/after' + - name: order + in: query + allowReserved: true + required: false + schema: + type: array + items: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + filter: + $ref: '#/components/schemas/EntityPredicate' + examples: + Get all service components: + value: + filter: + $all: + - kind: component + - spec.type: service + Get components owned by specific teams: + value: + filter: + $all: + - kind: component + - spec.owner: + $in: + - backend-team + - platform-team + Get non-production services: + value: + filter: + $all: + - kind: component + - spec.type: service + - $not: + spec.lifecycle: production /entity-facets: get: operationId: GetEntityFacets 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 8254df6bab..a185f9a359 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 @@ -25,6 +25,8 @@ import { EntitiesQueryResponse } from '../models/EntitiesQueryResponse.model'; import { Entity } from '../models/Entity.model'; import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model'; import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model'; +import { GetEntitiesByPredicates200Response } from '../models/GetEntitiesByPredicates200Response.model'; +import { GetEntitiesByPredicatesRequest } from '../models/GetEntitiesByPredicatesRequest.model'; import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model'; import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model'; import { ValidateEntity400Response } from '../models/ValidateEntity400Response.model'; @@ -53,6 +55,19 @@ export type GetEntities = { }; response: Array | Error | Error; }; +/** + * @public + */ +export type GetEntitiesByPredicates = { + body: GetEntitiesByPredicatesRequest; + query: { + limit?: number; + offset?: number; + after?: string; + order?: Array; + }; + response: GetEntitiesByPredicates200Response | Error | Error; +}; /** * @public */ @@ -208,6 +223,8 @@ export type EndpointMap = { '#get|/entities': GetEntities; + '#post|/entities/by-predicates': GetEntitiesByPredicates; + '#get|/entities/by-query': GetEntitiesByQuery; '#post|/entities/by-refs': GetEntitiesByRefs; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts new file mode 100644 index 0000000000..18b9038228 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts @@ -0,0 +1,36 @@ +/* + * 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. * +// ****************************************************************** +import { EntityPredicateOneOf } from '../models/EntityPredicateOneOf.model'; +import { EntityPredicateOneOf1 } from '../models/EntityPredicateOneOf1.model'; +import { EntityPredicateOneOf2 } from '../models/EntityPredicateOneOf2.model'; +import { EntityPredicateValue } from '../models/EntityPredicateValue.model'; + +/** + * A predicate-based filter supporting logical operators. - $all: All conditions must match (AND) - $any: At least one condition must match (OR) - $not: Negates the condition - $exists: Check if field exists - $in: Match any value in array + * @public + */ +export type EntityPredicate = + | EntityPredicateOneOf + | EntityPredicateOneOf1 + | EntityPredicateOneOf2 + | boolean + | number + | string + | { [key: string]: EntityPredicateValue }; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts new file mode 100644 index 0000000000..da6f4678b9 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts @@ -0,0 +1,27 @@ +/* + * 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. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * @public + */ +export interface EntityPredicateOneOf { + $all: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts new file mode 100644 index 0000000000..8e2b1217f5 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts @@ -0,0 +1,27 @@ +/* + * 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. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * @public + */ +export interface EntityPredicateOneOf1 { + $any: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts new file mode 100644 index 0000000000..af1ca66a3a --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts @@ -0,0 +1,27 @@ +/* + * 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. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * @public + */ +export interface EntityPredicateOneOf2 { + $not: EntityPredicate; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts new file mode 100644 index 0000000000..0ea00bef2f --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts @@ -0,0 +1,32 @@ +/* + * 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. * +// ****************************************************************** +import { EntityPredicateValueOneOf } from '../models/EntityPredicateValueOneOf.model'; +import { EntityPredicateValueOneOf1 } from '../models/EntityPredicateValueOneOf1.model'; + +/** + * Value for a field predicate + * @public + */ +export type EntityPredicateValue = + | EntityPredicateValueOneOf + | EntityPredicateValueOneOf1 + | boolean + | number + | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts new file mode 100644 index 0000000000..e260d2456a --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts @@ -0,0 +1,26 @@ +/* + * 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 EntityPredicateValueOneOf { + $exists: boolean; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts new file mode 100644 index 0000000000..1bea769175 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts @@ -0,0 +1,27 @@ +/* + * 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. * +// ****************************************************************** +import { EntityPredicateValueOneOf1InInner } from '../models/EntityPredicateValueOneOf1InInner.model'; + +/** + * @public + */ +export interface EntityPredicateValueOneOf1 { + $in: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts new file mode 100644 index 0000000000..45c7bfac4c --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts @@ -0,0 +1,24 @@ +/* + * 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 type EntityPredicateValueOneOf1InInner = boolean | number | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts new file mode 100644 index 0000000000..0acbfe559e --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts @@ -0,0 +1,32 @@ +/* + * 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. * +// ****************************************************************** +import { Entity } from '../models/Entity.model'; +import { GetEntitiesByPredicates200ResponsePageInfo } from '../models/GetEntitiesByPredicates200ResponsePageInfo.model'; + +/** + * @public + */ +export interface GetEntitiesByPredicates200Response { + /** + * The list of entities matching the predicate filter. + */ + items: Array; + pageInfo: GetEntitiesByPredicates200ResponsePageInfo; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts new file mode 100644 index 0000000000..64ea958943 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts @@ -0,0 +1,29 @@ +/* + * 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 GetEntitiesByPredicates200ResponsePageInfo { + /** + * The cursor for the next batch of entities. + */ + nextCursor?: string; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicatesRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicatesRequest.model.ts new file mode 100644 index 0000000000..2937e27df5 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicatesRequest.model.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2024 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. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * @public + */ +export interface GetEntitiesByPredicatesRequest { + filter?: EntityPredicate; +} 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 abdb58378c..abd35ed409 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts @@ -31,10 +31,21 @@ export * from '../models/EntityFacet.model'; export * from '../models/EntityFacetsResponse.model'; export * from '../models/EntityLink.model'; export * from '../models/EntityMeta.model'; +export * from '../models/EntityPredicate.model'; +export * from '../models/EntityPredicateOneOf.model'; +export * from '../models/EntityPredicateOneOf1.model'; +export * from '../models/EntityPredicateOneOf2.model'; +export * from '../models/EntityPredicateValue.model'; +export * from '../models/EntityPredicateValueOneOf.model'; +export * from '../models/EntityPredicateValueOneOf1.model'; +export * from '../models/EntityPredicateValueOneOf1InInner.model'; export * from '../models/EntityRelation.model'; export * from '../models/ErrorError.model'; export * from '../models/ErrorRequest.model'; export * from '../models/ErrorResponse.model'; +export * from '../models/GetEntitiesByPredicates200Response.model'; +export * from '../models/GetEntitiesByPredicates200ResponsePageInfo.model'; +export * from '../models/GetEntitiesByPredicatesRequest.model'; export * from '../models/GetEntitiesByRefsRequest.model'; export * from '../models/GetLocations200ResponseInner.model'; export * from '../models/GetLocationsByQueryRequest.model'; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index b4078df721..5fe2c642b7 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -262,6 +262,110 @@ export const spec = { description: 'A type representing all allowed JSON object values.', additionalProperties: {}, }, + EntityPredicate: { + description: + 'A predicate-based filter supporting logical operators.\n- $all: All conditions must match (AND)\n- $any: At least one condition must match (OR)\n- $not: Negates the condition\n- $exists: Check if field exists\n- $in: Match any value in array\n', + oneOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'boolean', + }, + { + type: 'object', + additionalProperties: false, + properties: { + $all: { + type: 'array', + items: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + }, + required: ['$all'], + }, + { + type: 'object', + additionalProperties: false, + properties: { + $any: { + type: 'array', + items: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + }, + required: ['$any'], + }, + { + type: 'object', + additionalProperties: false, + properties: { + $not: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + required: ['$not'], + }, + { + type: 'object', + additionalProperties: { + $ref: '#/components/schemas/EntityPredicateValue', + }, + }, + ], + }, + EntityPredicateValue: { + description: 'Value for a field predicate', + oneOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'boolean', + }, + { + type: 'object', + additionalProperties: false, + properties: { + $exists: { + type: 'boolean', + }, + }, + required: ['$exists'], + }, + { + type: 'object', + additionalProperties: false, + properties: { + $in: { + type: 'array', + items: { + oneOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'boolean', + }, + ], + }, + }, + }, + required: ['$in'], + }, + ], + }, MapStringString: { type: 'object', properties: {}, @@ -1229,6 +1333,149 @@ export const spec = { ], }, }, + '/entities/by-predicates': { + post: { + operationId: 'GetEntitiesByPredicates', + tags: ['Entity'], + description: + 'Query entities using predicate-based filters. This endpoint supports a more\nexpressive filter syntax with logical operators ($all, $any, $not) and\nvalue operators ($exists, $in).\n\nExample filter:\n```json\n{\n "filter": {\n "$all": [\n {"kind": "component"},\n {"$any": [\n {"spec.type": "service"},\n {"spec.type": "website"}\n ]},\n {"$not": {"spec.lifecycle": "experimental"}}\n ]\n }\n}\n```\n', + responses: { + '200': { + description: 'Ok', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { + type: 'array', + items: { + $ref: '#/components/schemas/Entity', + }, + description: + 'The list of entities matching the predicate filter.', + }, + pageInfo: { + type: 'object', + properties: { + nextCursor: { + type: 'string', + description: + 'The cursor for the next batch of entities.', + }, + }, + }, + }, + required: ['items', 'pageInfo'], + }, + }, + }, + }, + '400': { + $ref: '#/components/responses/ErrorResponse', + }, + default: { + $ref: '#/components/responses/ErrorResponse', + }, + }, + security: [ + {}, + { + JWT: [], + }, + ], + parameters: [ + { + $ref: '#/components/parameters/limit', + }, + { + $ref: '#/components/parameters/offset', + }, + { + $ref: '#/components/parameters/after', + }, + { + name: 'order', + in: 'query', + allowReserved: true, + required: false, + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + properties: { + filter: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + }, + examples: { + 'Get all service components': { + value: { + filter: { + $all: [ + { + kind: 'component', + }, + { + 'spec.type': 'service', + }, + ], + }, + }, + }, + 'Get components owned by specific teams': { + value: { + filter: { + $all: [ + { + kind: 'component', + }, + { + 'spec.owner': { + $in: ['backend-team', 'platform-team'], + }, + }, + ], + }, + }, + }, + 'Get non-production services': { + value: { + filter: { + $all: [ + { + kind: 'component', + }, + { + 'spec.type': 'service', + }, + { + $not: { + 'spec.lifecycle': 'production', + }, + }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + }, '/entity-facets': { get: { operationId: 'GetEntityFacets', diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index 894949a691..cf262c7778 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -29,6 +29,7 @@ describe('AuthorizedEntitiesCatalog', () => { const fakeCatalog = { entities: jest.fn(), entitiesBatch: jest.fn(), + queryEntitiesByPredicate: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), facets: jest.fn(), diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index d3d0dba5b3..831a3bb76b 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -32,6 +32,7 @@ import { EntityAncestryResponse, EntityFacetsRequest, EntityFacetsResponse, + EntityPredicateRequest, QueryEntitiesRequest, QueryEntitiesResponse, } from '../catalog/types'; @@ -196,6 +197,36 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { return this.entitiesCatalog.queryEntities(request); } + async queryEntitiesByPredicate( + request?: EntityPredicateRequest, + ): Promise { + if (!request) { + return { + entities: { type: 'object', entities: [] }, + pageInfo: { hasNextPage: false }, + }; + } + + const authorizeDecision = ( + await this.permissionApi.authorizeConditional( + [{ permission: catalogEntityReadPermission }], + { credentials: request.credentials }, + ) + )[0]; + + if (authorizeDecision.result === AuthorizeResult.DENY) { + return { + entities: { type: 'object', entities: [] }, + pageInfo: { hasNextPage: false }, + }; + } + + // Note: For CONDITIONAL results, we pass through to the underlying catalog + // since EntityPredicate filters are separate from EntityFilter permission conditions. + // The permission filter would need to be applied separately if needed. + return this.entitiesCatalog.queryEntitiesByPredicate(request); + } + async removeEntityByUid( uid: string, options: { credentials: BackstageCredentials }, diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index aa3ed3a551..2a792f1de1 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -72,6 +72,7 @@ describe('createRouter readonly disabled', () => { beforeEach(async () => { entitiesCatalog = { entities: jest.fn(), + queryEntitiesByPredicate: jest.fn(), entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), @@ -1120,6 +1121,7 @@ describe('createRouter readonly and raw json enabled', () => { beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), + queryEntitiesByPredicate: jest.fn(), entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), @@ -1336,6 +1338,7 @@ describe('NextRouter permissioning', () => { beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), + queryEntitiesByPredicate: jest.fn(), entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 11e459ae82..e26a0c1233 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -30,7 +30,10 @@ import { } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { InputError, serializeError } from '@backstage/errors'; -import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; +import { + EntityPredicate, + LocationAnalyzer, +} from '@backstage/plugin-catalog-node'; import express from 'express'; import yn from 'yn'; import { z } from 'zod'; @@ -258,6 +261,53 @@ export async function createRouter( throw err; } }) + .post('/entities/by-predicates', async (req, res) => { + const auditorEvent = await auditor.createEvent({ + eventId: 'entity-fetch', + request: req, + meta: { + queryType: 'by-predicates', + }, + }); + + try { + const filter = req.body.filter as EntityPredicate | undefined; + const order = parseEntityOrderParams(req.query); + const pagination = parseEntityPaginationParams(req.query); + const credentials = await httpAuth.credentials(req); + + const { entities, pageInfo } = + await entitiesCatalog.queryEntitiesByPredicate({ + filter, + order, + pagination, + credentials, + }); + + const meta = { + pageInfo: { + ...(pageInfo.hasNextPage && { + nextCursor: pageInfo.endCursor, + }), + }, + }; + + await auditorEvent?.success({ meta }); + + await writeEntitiesResponse({ + res, + items: entities, + alwaysUseObjectMode: enableRelationsCompatibility, + responseWrapper: items => ({ + items, + ...meta, + }), + }); + } catch (err) { + await auditorEvent?.fail({ error: err }); + throw err; + } + }) .get('/entities/by-query', async (req, res) => { const auditorEvent = await auditor.createEvent({ eventId: 'entity-fetch', From ebeebd9012886f139409fb37de50997b0c45826d Mon Sep 17 00:00:00 2001 From: Nilay1999 Date: Thu, 22 Jan 2026 22:39:07 +0530 Subject: [PATCH 004/118] refactor(client): :recycle: update queryEntities to support query payload - add query support to do advanced search using queryEntities - update core query of queryEntitiesByPredicate to handle cursor based pagination Signed-off-by: Nilay1999 --- .../catalog-client/src/CatalogClient.test.ts | 305 ++++++++++++++++++ packages/catalog-client/src/CatalogClient.ts | 96 +++++- packages/catalog-client/src/utils.ts | 13 + plugins/catalog-backend/src/catalog/types.ts | 12 +- .../src/service/DefaultEntitiesCatalog.ts | 138 ++++++-- plugins/catalog-backend/src/service/util.ts | 2 +- 6 files changed, 530 insertions(+), 36 deletions(-) diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index d62323852e..31c5d62046 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -540,6 +540,311 @@ describe('CatalogClient', () => { }); }); + describe('queryEntities with predicate-based queries (POST endpoint)', () => { + const defaultResponse = { + items: [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'service-1', + namespace: 'default', + }, + spec: { + type: 'service', + owner: 'team-a', + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'service-2', + namespace: 'default', + }, + spec: { + type: 'service', + owner: 'team-b', + }, + }, + ], + pageInfo: {}, + }; + + it('should use POST endpoint when query is provided', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.method).toBe('POST'); + expect(req.body).toMatchObject({ + query: { + kind: 'component', + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + const response = await client.queryEntities({ + query: { kind: 'component' }, + limit: 20, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + expect(response.items).toEqual(defaultResponse.items); + expect(response.totalItems).toBe(defaultResponse.items.length); + }); + + it('should throw error when both filter and query are provided', async () => { + await expect( + client.queryEntities({ + filter: { kind: 'component' }, + query: { kind: 'component' }, + } as any), + ).rejects.toThrow( + 'Cannot specify both "filter" and "query" in the same request', + ); + }); + + it('should support $all operator', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + $all: [{ kind: 'component' }, { 'spec.type': 'service' }], + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + $all: [{ kind: 'component' }, { 'spec.type': 'service' }], + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should support $any operator', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should support $not operator', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + $not: { 'spec.lifecycle': 'experimental' }, + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + $not: { 'spec.lifecycle': 'experimental' }, + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should support $exists operator', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + 'spec.owner': { $exists: true }, + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + 'spec.owner': { $exists: true }, + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should support $in operator', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + 'spec.owner': { $in: ['team-a', 'team-b', 'team-c'] }, + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + 'spec.owner': { $in: ['team-a', 'team-b', 'team-c'] }, + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should support complex nested predicates', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + $all: [ + { kind: 'component' }, + { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + { + $not: { + 'spec.lifecycle': 'experimental', + }, + }, + ], + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + $all: [ + { kind: 'component' }, + { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + { + $not: { + 'spec.lifecycle': 'experimental', + }, + }, + ], + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should send orderFields with correct format (field,order)', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + const url = new URL(req.url); + expect(url.searchParams.getAll('orderField')).toEqual([ + 'metadata.name,asc', + ]); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { kind: 'component' }, + orderFields: { field: 'metadata.name', order: 'asc' }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should send multiple orderFields with correct format', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + const url = new URL(req.url); + expect(url.searchParams.getAll('orderField')).toEqual([ + 'metadata.name,asc', + 'spec.type,desc', + ]); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { kind: 'component' }, + orderFields: [ + { field: 'metadata.name', order: 'asc' }, + { field: 'spec.type', order: 'desc' }, + ], + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should send limit and offset parameters', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + const url = new URL(req.url); + expect(url.searchParams.get('limit')).toBe('50'); + expect(url.searchParams.get('offset')).toBe('10'); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { kind: 'component' }, + limit: 50, + offset: 10, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should not allow cursor with query (cursor takes precedence)', async () => { + // When cursor is provided, it's not an initial request, so the query + // parameter is ignored and it goes to GET endpoint + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + // Should use GET endpoint, not POST + expect(req.method).toBe('GET'); + return res(ctx.json({ items: [], totalItems: 0, pageInfo: {} })); + }); + + server.use(rest.get(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + // This will use GET endpoint with cursor, ignoring the query parameter + await client.queryEntities({ + cursor: 'some-cursor', + query: { kind: 'component' }, + } as any); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should handle errors from POST endpoint', async () => { + const mockedEndpoint = jest + .fn() + .mockImplementation((_req, res, ctx) => res(ctx.status(400))); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await expect(() => + client.queryEntities({ query: { kind: 'component' } }), + ).rejects.toThrow(/Request failed with 400/); + }); + }); + describe('streamEntities', () => { const defaultResponse: QueryEntitiesResponse = { items: [ diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index a537e8b3a5..2d7fa7d957 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -46,7 +46,11 @@ import { StreamEntitiesRequest, ValidateEntityResponse, } from './types/api'; -import { isQueryEntitiesInitialRequest, splitRefsIntoChunks } from './utils'; +import { + isQueryEntitiesInitialRequest, + splitRefsIntoChunks, + cursorContainsQuery, +} from './utils'; import { DefaultApiClient, GetLocationsByQueryRequest, @@ -266,6 +270,30 @@ export class CatalogClient implements CatalogApi { request: QueryEntitiesRequest = {}, options?: CatalogRequestOptions, ): Promise { + // Validate that filter and query are mutually exclusive + if ( + isQueryEntitiesInitialRequest(request) && + request.filter && + request.query + ) { + throw new Error( + 'Cannot specify both "filter" and "query" in the same request. Use "filter" for traditional key-value filtering or "query" for predicate-based filtering.', + ); + } + + // Route to POST endpoint if query predicate is provided (initial request) + if (isQueryEntitiesInitialRequest(request) && request.query) { + return this.queryEntitiesByPredicate(request, options); + } + + // Route to POST endpoint if cursor contains a query predicate (pagination) + if ( + !isQueryEntitiesInitialRequest(request) && + cursorContainsQuery(request.cursor) + ) { + return this.queryEntitiesByPredicate(request, options); + } + const params: Partial< Parameters[0]['query'] > = {}; @@ -320,6 +348,72 @@ export class CatalogClient implements CatalogApi { ); } + /** + * Query entities using predicate-based filters (POST endpoint). + * @internal + */ + private async queryEntitiesByPredicate( + request: QueryEntitiesRequest, + options?: CatalogRequestOptions, + ): Promise { + const params: { + limit?: number; + offset?: number; + after?: string; + orderField?: string[]; + cursor?: string; + fields?: string[]; + } = {}; + + let query; + + if (isQueryEntitiesInitialRequest(request)) { + // Initial request with query predicate + const { query: requestQuery, limit, offset, orderFields } = request; + query = requestQuery; + + if (limit !== undefined) { + params.limit = limit; + } + if (offset !== undefined) { + params.offset = offset; + } + if (orderFields !== undefined) { + params.orderField = ( + Array.isArray(orderFields) ? orderFields : [orderFields] + ).map(({ field, order }) => `${field},${order}`); + } + } else { + // Cursor-based pagination request + const { cursor, limit } = request; + + params.after = cursor; + if (limit !== undefined) { + params.limit = limit; + } + // Query will be extracted from cursor on the backend + query = undefined; + } + + const response = await this.apiClient.queryEntitiesByPredicate( + { + body: query ? { query } : {}, + query: params, + }, + options, + ); + + const result = await this.requestRequired(response); + + return { + items: result.items, + totalItems: result.items.length, + pageInfo: { + nextCursor: result.pageInfo?.nextCursor, + }, + }; + } + /** * {@inheritdoc CatalogApi.getEntityByRef} */ diff --git a/packages/catalog-client/src/utils.ts b/packages/catalog-client/src/utils.ts index a16cf53db1..e7713623b9 100644 --- a/packages/catalog-client/src/utils.ts +++ b/packages/catalog-client/src/utils.ts @@ -26,6 +26,19 @@ export function isQueryEntitiesInitialRequest( return !(request as QueryEntitiesCursorRequest).cursor; } +/** + * Check if a cursor contains a predicate query by attempting to decode it. + * @internal + */ +export function cursorContainsQuery(cursor: string): boolean { + try { + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf8')); + return !!decoded.query; + } catch { + return false; + } +} + /** * Takes a set of entity refs, and splits them into chunks (groups) such that * the total string length in each chunk does not exceed the default Express.js diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 1b87352318..aad176e390 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -53,7 +53,7 @@ export type EntitiesRequest = { }; export type EntityPredicateRequest = { - filter?: EntityPredicate; + query?: EntityPredicate; order?: EntityOrder[]; pagination?: EntityPagination; credentials: BackstageCredentials; @@ -228,6 +228,11 @@ export interface QueryEntitiesInitialRequest { limit?: number; offset?: number; filter?: EntityFilter; + /** + * Predicate-based query for filtering entities. + * Mutually exclusive with filter. + */ + query?: EntityPredicate; orderFields?: EntityOrder[]; fullTextFilter?: { term: string; @@ -289,6 +294,11 @@ export type Cursor = { * A filter to be applied to the full list of entities. */ filter?: EntityFilter; + /** + * A predicate-based query to be applied to the full list of entities. + * Mutually exclusive with filter. + */ + query?: EntityPredicate; /** * true if the cursor is a previous cursor. */ diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index c7b14282ef..151e345f5b 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -46,6 +46,8 @@ import { import { Stitcher } from '../stitching/types'; import { + decodeCursor, + encodeCursor, expandLegacyCompoundRelationsInEntity, isQueryEntitiesCursorRequest, isQueryEntitiesInitialRequest, @@ -221,70 +223,133 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { const db = this.database; const { limit, offset } = parsePagination(request?.pagination); - let entitiesQuery = - db('final_entities').select('final_entities.*'); + const cursor = request?.pagination?.after + ? decodeCursor(request.pagination.after) + : { + query: request?.query, + orderFields: request?.order || [], + isPrevious: false, + orderFieldValues: undefined, + firstSortFieldValues: undefined, + }; - request?.order?.forEach(({ field }, index) => { - const alias = `order_${index}`; - entitiesQuery = entitiesQuery.leftOuterJoin( - { [alias]: 'search' }, - function search(inner) { + // Use query from cursor if not provided in request (pagination case) + const effectiveQuery = request?.query ?? cursor.query; + const sortField = cursor.orderFields?.[0]; + + let entitiesQuery = db('final_entities'); + + // Join with search table if we have a sort field + if (sortField) { + entitiesQuery = entitiesQuery + .distinct() + .leftOuterJoin({ order_0: 'search' }, function search(inner) { inner - .on(`${alias}.entity_id`, 'final_entities.entity_id') - .andOn(`${alias}.key`, db.raw('?', [field])); - }, - ); - }); + .on('order_0.entity_id', 'final_entities.entity_id') + .andOn('order_0.key', db.raw('?', [sortField.field])); + }) + .select({ + entity_id: 'final_entities.entity_id', + final_entity: 'final_entities.final_entity', + value: 'order_0.value', + }); + } else { + entitiesQuery = entitiesQuery.select({ + entity_id: 'final_entities.entity_id', + final_entity: 'final_entities.final_entity', + }); + } entitiesQuery = entitiesQuery.whereNotNull('final_entities.final_entity'); - if (request?.filter) { + // Apply predicate filter from cursor + if (effectiveQuery) { entitiesQuery = applyPredicateEntityFilterToQuery({ - filter: request.filter, + filter: effectiveQuery, targetQuery: entitiesQuery, onEntityIdField: 'final_entities.entity_id', knex: db, }); } - request?.order?.forEach(({ order }, index) => { + // Apply cursor-based pagination (keyset pagination) + if (cursor.orderFieldValues) { + if (cursor.orderFieldValues.length === 2) { + const [sortValue, entityId] = cursor.orderFieldValues; + const isOrderingDescending = sortField?.order === 'desc'; + entitiesQuery = entitiesQuery.andWhere(function nested() { + this.where( + 'order_0.value', + isOrderingDescending ? '<' : '>', + sortValue, + ) + .orWhere('order_0.value', '=', sortValue) + .andWhere('final_entities.entity_id', '>', entityId); + }); + } else if (cursor.orderFieldValues.length === 1) { + const [entityId] = cursor.orderFieldValues; + entitiesQuery = entitiesQuery.andWhere( + 'final_entities.entity_id', + '>', + entityId, + ); + } + } + + if (sortField) { if (db.client.config.client === 'pg') { entitiesQuery = entitiesQuery.orderBy([ - { column: `order_${index}.value`, order, nulls: 'last' }, + { column: 'order_0.value', order: sortField.order, nulls: 'last' }, + { column: 'final_entities.entity_id', order: 'asc' }, ]); } else { entitiesQuery = entitiesQuery.orderBy([ - { column: `order_${index}.value`, order: undefined, nulls: 'last' }, - { column: `order_${index}.value`, order }, + { column: 'order_0.value', order: undefined, nulls: 'last' }, + { column: 'order_0.value', order: sortField.order }, + { column: 'final_entities.entity_id', order: 'asc' }, ]); } - }); - - if (!request?.order) { - entitiesQuery = entitiesQuery.orderBy('final_entities.entity_ref', 'asc'); } else { - entitiesQuery.orderBy('final_entities.entity_id', 'asc'); + entitiesQuery = entitiesQuery.orderBy('final_entities.entity_id', 'asc'); } - if (limit !== undefined) { - entitiesQuery = entitiesQuery.limit(limit + 1); - } - if (offset !== undefined) { + // Apply a manually set initial offset (only when not using cursor pagination) + if (!request?.pagination?.after && offset !== undefined) { entitiesQuery = entitiesQuery.offset(offset); } + const effectiveLimit = limit ?? DEFAULT_LIMIT; + entitiesQuery = entitiesQuery.limit(effectiveLimit + 1); let rows = await entitiesQuery; let pageInfo: DbPageInfo; - if (limit === undefined || rows.length <= limit) { + + if (rows.length <= effectiveLimit) { pageInfo = { hasNextPage: false }; } else { + // Remove the extra row rows = rows.slice(0, -1); + + const lastRow = rows[rows.length - 1]; + const firstRow = rows[0]; + + // Create proper cursor with query field + const nextCursor: Cursor = { + query: effectiveQuery, + orderFields: cursor.orderFields || [], + orderFieldValues: sortField + ? [(lastRow as any).value, lastRow.entity_id] + : [lastRow.entity_id], + isPrevious: false, + firstSortFieldValues: + cursor.firstSortFieldValues || + (sortField + ? [(firstRow as any).value, firstRow.entity_id] + : [firstRow.entity_id]), + }; + pageInfo = { hasNextPage: true, - endCursor: stringifyPagination({ - limit, - offset: (offset ?? 0) + limit, - }), + endCursor: encodeCursor(nextCursor), }; } @@ -831,11 +896,18 @@ function parseCursorFromRequest( if (isQueryEntitiesInitialRequest(request)) { const { filter, + query, orderFields: sortFields = [], fullTextFilter, skipTotalItems = false, } = request; - return { filter, orderFields: sortFields, fullTextFilter, skipTotalItems }; + return { + filter, + query, + orderFields: sortFields, + fullTextFilter, + skipTotalItems, + }; } if (isQueryEntitiesCursorRequest(request)) { return { diff --git a/plugins/catalog-backend/src/service/util.ts b/plugins/catalog-backend/src/service/util.ts index 7e0e8b42f9..dbdbd374cf 100644 --- a/plugins/catalog-backend/src/service/util.ts +++ b/plugins/catalog-backend/src/service/util.ts @@ -122,7 +122,7 @@ export const cursorParser: z.ZodSchema = z.object({ orderFieldValues: z.array(z.string().or(z.null())), filter: entityFilterParser.optional(), isPrevious: z.boolean(), - query: z.string().optional(), + query: z.any().optional(), firstSortFieldValues: z.array(z.string().or(z.null())).optional(), totalItems: z.number().optional(), }); From 8ddce231154066516f618837e40d1938da1338c7 Mon Sep 17 00:00:00 2001 From: Nilay1999 Date: Thu, 22 Jan 2026 22:39:42 +0530 Subject: [PATCH 005/118] =?UTF-8?q?refactor(api):=20=E2=9C=A8=20regenerate?= =?UTF-8?q?=20OpenAPI=20models=20for=20updated=20predicate-based=20endpoin?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update `/by-predicate` endpoint name to POST `/by-query` - regenerate openapi models to fix generic type names Signed-off-by: Nilay1999 --- .../openapi/generated/apis/Api.client.ts | 45 +++++ .../generated/models/EntityPredicate.model.ts | 36 ++++ .../models/EntityPredicateAll.model.ts | 3 +- .../models/EntityPredicateAny.model.ts | 28 +++ .../models/EntityPredicateExists.model.ts | 3 +- .../models/EntityPredicateIn.model.ts | 28 +++ .../models/EntityPredicateInInInner.model.ts | 2 +- .../models/EntityPredicateNot.model.ts | 3 +- .../models/EntityPredicateValue.model.ts | 32 ++++ ...eryEntitiesByPredicate200Response.model.ts | 6 +- ...iesByPredicate200ResponsePageInfo.model.ts | 2 +- .../QueryEntitiesByPredicateRequest.model.ts | 4 +- .../schema/openapi/generated/models/index.ts | 11 ++ packages/catalog-client/src/types/api.ts | 28 ++- packages/catalog-client/src/types/index.ts | 9 + .../catalog-client/src/types/predicate.ts | 122 +++++++++++++ .../catalog-backend/src/schema/openapi.yaml | 133 +++++++------- .../openapi/generated/apis/Api.server.ts | 34 ++-- .../generated/models/EntityPredicate.model.ts | 12 +- .../models/EntityPredicateAll.model.ts | 28 +++ .../models/EntityPredicateAny.model.ts | 28 +++ .../models/EntityPredicateExists.model.ts | 27 +++ .../models/EntityPredicateIn.model.ts | 28 +++ .../models/EntityPredicateInInInner.model.ts | 24 +++ .../models/EntityPredicateNot.model.ts | 28 +++ .../models/EntityPredicateValue.model.ts | 8 +- .../EntityPredicateValueOneOf1.model.ts | 27 --- ...eryEntitiesByPredicate200Response.model.ts | 32 ++++ ...iesByPredicate200ResponsePageInfo.model.ts | 29 +++ ... QueryEntitiesByPredicateRequest.model.ts} | 6 +- .../schema/openapi/generated/models/index.ts | 18 +- .../src/schema/openapi/generated/router.ts | 165 +++++++++--------- .../src/service/createRouter.ts | 11 +- 33 files changed, 777 insertions(+), 223 deletions(-) create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts rename plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts => packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts (92%) create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts rename plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts => packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts (92%) create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts rename plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts => packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts (91%) rename plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts => packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts (93%) create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts rename plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts => packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts (80%) rename plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts => packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts (93%) rename plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts => packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts (92%) create mode 100644 packages/catalog-client/src/types/predicate.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts rename plugins/catalog-backend/src/schema/openapi/generated/models/{GetEntitiesByPredicatesRequest.model.ts => QueryEntitiesByPredicateRequest.model.ts} (87%) 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 4530cd795c..bc34d1c769 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 @@ -29,6 +29,8 @@ import { Entity } from '../models/Entity.model'; import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model'; import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model'; import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model'; +import { QueryEntitiesByPredicate200Response } from '../models/QueryEntitiesByPredicate200Response.model'; +import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model'; import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model'; import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model'; import { AnalyzeLocationRequest } from '../models/AnalyzeLocationRequest.model'; @@ -139,6 +141,18 @@ export type GetEntityFacets = { filter?: Array; }; }; +/** + * @public + */ +export type QueryEntitiesByPredicate = { + body: QueryEntitiesByPredicateRequest; + query: { + limit?: number; + offset?: number; + orderField?: Array; + after?: string; + }; +}; /** * @public */ @@ -449,6 +463,37 @@ export class DefaultApiClient { }); } + /** + * Query entities using predicate-based filters. This endpoint provides an alternative filtering method with a more expressive filter syntax supporting logical operators ($all, $any, $not) and value operators ($exists, $in). Example query: ```json { \"query\": { \"$all\": [ {\"kind\": \"component\"}, {\"$any\": [ {\"spec.type\": \"service\"}, {\"spec.type\": \"website\"} ]}, {\"$not\": {\"spec.lifecycle\": \"experimental\"}} ] } } ``` + * @param queryEntitiesByPredicateRequest - + * @param limit - Number of records to return in the response. + * @param offset - Number of records to skip in the query page. + * @param orderField - By default the entities are returned ordered by their internal uid. You can customize the `orderField` query parameters to affect that ordering. For example, to return entities by their name: `/entities/by-query?orderField=metadata.name,asc` Each parameter can be followed by `asc` for ascending lexicographical order or `desc` for descending (reverse) lexicographical order. + * @param after - Pointer to the previous page of results. + */ + public async queryEntitiesByPredicate( + // @ts-ignore + request: QueryEntitiesByPredicate, + options?: RequestOptions, + ): Promise> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/entities/by-query{?limit,offset,orderField*,after}`; + + const uri = parser.parse(uriTemplate).expand({ + ...request.query, + }); + + 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/EntityPredicate.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts new file mode 100644 index 0000000000..bdc862ca50 --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts @@ -0,0 +1,36 @@ +/* + * 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. * +// ****************************************************************** +import { EntityPredicateAll } from '../models/EntityPredicateAll.model'; +import { EntityPredicateAny } from '../models/EntityPredicateAny.model'; +import { EntityPredicateNot } from '../models/EntityPredicateNot.model'; +import { EntityPredicateValue } from '../models/EntityPredicateValue.model'; + +/** + * A predicate-based filter supporting logical operators. - $all: All conditions must match (AND) - $any: At least one condition must match (OR) - $not: Negates the condition - $exists: Check if field exists - $in: Match any value in array + * @public + */ +export type EntityPredicate = + | EntityPredicateAll + | EntityPredicateAny + | EntityPredicateNot + | boolean + | number + | string + | { [key: string]: EntityPredicateValue }; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts similarity index 92% rename from plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts index da6f4678b9..080b8307b4 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts @@ -20,8 +20,9 @@ import { EntityPredicate } from '../models/EntityPredicate.model'; /** + * All conditions must match (AND logic) * @public */ -export interface EntityPredicateOneOf { +export interface EntityPredicateAll { $all: Array; } diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts new file mode 100644 index 0000000000..170ea3ab0c --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts @@ -0,0 +1,28 @@ +/* + * 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. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * At least one condition must match (OR logic) + * @public + */ +export interface EntityPredicateAny { + $any: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts similarity index 92% rename from plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts index e260d2456a..7800fcd20c 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts @@ -19,8 +19,9 @@ // ****************************************************************** /** + * Check if field exists * @public */ -export interface EntityPredicateValueOneOf { +export interface EntityPredicateExists { $exists: boolean; } diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts new file mode 100644 index 0000000000..574ab4f45b --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts @@ -0,0 +1,28 @@ +/* + * 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. * +// ****************************************************************** +import { EntityPredicateInInInner } from '../models/EntityPredicateInInInner.model'; + +/** + * Match any value in array + * @public + */ +export interface EntityPredicateIn { + $in: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts similarity index 91% rename from plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts index 45c7bfac4c..2b4e3d7c2f 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts @@ -21,4 +21,4 @@ /** * @public */ -export type EntityPredicateValueOneOf1InInner = boolean | number | string; +export type EntityPredicateInInInner = boolean | number | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts similarity index 93% rename from plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts index af1ca66a3a..e56279b0fc 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts @@ -20,8 +20,9 @@ import { EntityPredicate } from '../models/EntityPredicate.model'; /** + * Negates the condition * @public */ -export interface EntityPredicateOneOf2 { +export interface EntityPredicateNot { $not: EntityPredicate; } diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts new file mode 100644 index 0000000000..c230c2a984 --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts @@ -0,0 +1,32 @@ +/* + * 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. * +// ****************************************************************** +import { EntityPredicateExists } from '../models/EntityPredicateExists.model'; +import { EntityPredicateIn } from '../models/EntityPredicateIn.model'; + +/** + * Value for a field predicate + * @public + */ +export type EntityPredicateValue = + | EntityPredicateExists + | EntityPredicateIn + | boolean + | number + | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts similarity index 80% rename from plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts index 0acbfe559e..3c4b74cd10 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts @@ -18,15 +18,15 @@ // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** import { Entity } from '../models/Entity.model'; -import { GetEntitiesByPredicates200ResponsePageInfo } from '../models/GetEntitiesByPredicates200ResponsePageInfo.model'; +import { QueryEntitiesByPredicate200ResponsePageInfo } from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; /** * @public */ -export interface GetEntitiesByPredicates200Response { +export interface QueryEntitiesByPredicate200Response { /** * The list of entities matching the predicate filter. */ items: Array; - pageInfo: GetEntitiesByPredicates200ResponsePageInfo; + pageInfo: QueryEntitiesByPredicate200ResponsePageInfo; } diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts similarity index 93% rename from plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts index 64ea958943..3ef9f73758 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts @@ -21,7 +21,7 @@ /** * @public */ -export interface GetEntitiesByPredicates200ResponsePageInfo { +export interface QueryEntitiesByPredicate200ResponsePageInfo { /** * The cursor for the next batch of entities. */ diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts similarity index 92% rename from plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index 8e2b1217f5..8b8276c9da 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -22,6 +22,6 @@ import { EntityPredicate } from '../models/EntityPredicate.model'; /** * @public */ -export interface EntityPredicateOneOf1 { - $any: Array; +export interface QueryEntitiesByPredicateRequest { + query?: EntityPredicate; } 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 abdb58378c..9d7ee33602 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/index.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/index.ts @@ -31,6 +31,14 @@ export * from '../models/EntityFacet.model'; export * from '../models/EntityFacetsResponse.model'; export * from '../models/EntityLink.model'; export * from '../models/EntityMeta.model'; +export * from '../models/EntityPredicate.model'; +export * from '../models/EntityPredicateAll.model'; +export * from '../models/EntityPredicateAny.model'; +export * from '../models/EntityPredicateExists.model'; +export * from '../models/EntityPredicateIn.model'; +export * from '../models/EntityPredicateInInInner.model'; +export * from '../models/EntityPredicateNot.model'; +export * from '../models/EntityPredicateValue.model'; export * from '../models/EntityRelation.model'; export * from '../models/ErrorError.model'; export * from '../models/ErrorRequest.model'; @@ -45,6 +53,9 @@ export * from '../models/LocationsQueryResponse.model'; export * from '../models/LocationsQueryResponsePageInfo.model'; export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; +export * from '../models/QueryEntitiesByPredicate200Response.model'; +export * from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; +export * from '../models/QueryEntitiesByPredicateRequest.model'; export * from '../models/RecursivePartialEntity.model'; export * from '../models/RecursivePartialEntityMeta.model'; export * from '../models/RecursivePartialEntityMetaAllOf.model'; diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 12c5e00c03..dda003ec90 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -418,16 +418,42 @@ export type QueryEntitiesRequest = * The method takes this type in an initial pagination request, * when requesting the first batch of entities. * - * The properties filter, sortField, query and sortFieldOrder, are going + * The properties filter, query, sortField and sortFieldOrder, are going * to be immutable for the entire lifecycle of the following requests. * + * @remarks + * + * Either `filter` or `query` can be provided, but not both: + * - `filter`: Uses the traditional key-value filter syntax (GET endpoint) + * - `query`: Uses the predicate-based filter syntax with logical operators (POST endpoint) + * * @public */ export type QueryEntitiesInitialRequest = { fields?: string[]; limit?: number; offset?: number; + /** + * Traditional key-value based filter. Mutually exclusive with `query`. + */ filter?: EntityFilterQuery; + /** + * Predicate-based filter with logical operators ($all, $any, $not, $exists, $in). + * Mutually exclusive with `filter`. + * + * @example + * ```typescript + * { + * query: { + * $all: [ + * { kind: 'component' }, + * { 'spec.type': 'service' } + * ] + * } + * } + * ``` + */ + query?: FilterPredicate; orderFields?: EntityOrderQuery; fullTextFilter?: { term: string; diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts index b280f1874f..20a5f3303d 100644 --- a/packages/catalog-client/src/types/index.ts +++ b/packages/catalog-client/src/types/index.ts @@ -44,4 +44,13 @@ export type { QueryLocationsInitialRequest, QueryLocationsResponse, } from './api'; +export type { + EntityPredicate, + EntityPredicateAll, + EntityPredicateAny, + EntityPredicateNot, + EntityPredicateValue, + EntityPredicateExists, + EntityPredicateIn, +} from './predicate'; export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status'; diff --git a/packages/catalog-client/src/types/predicate.ts b/packages/catalog-client/src/types/predicate.ts new file mode 100644 index 0000000000..11b081d650 --- /dev/null +++ b/packages/catalog-client/src/types/predicate.ts @@ -0,0 +1,122 @@ +/* + * 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. + */ + +/** + * A predicate-based filter supporting logical operators. + * + * @remarks + * + * This provides a more expressive filter syntax compared to the traditional + * EntityFilterQuery. It supports: + * - Logical operators: $all (AND), $any (OR), $not (negation) + * - Value operators: $exists, $in + * - Direct field matching with primitive values + * + * @example + * ```typescript + * // Match all service components + * { + * $all: [ + * { kind: 'component' }, + * { 'spec.type': 'service' } + * ] + * } + * + * // Match components owned by specific teams + * { + * $all: [ + * { kind: 'component' }, + * { 'spec.owner': { $in: ['backend-team', 'platform-team'] } } + * ] + * } + * + * // Match non-production services + * { + * $all: [ + * { kind: 'component' }, + * { 'spec.type': 'service' }, + * { $not: { 'spec.lifecycle': 'production' } } + * ] + * } + * ``` + * + * @public + */ +export type EntityPredicate = + | EntityPredicateAll + | EntityPredicateAny + | EntityPredicateNot + | boolean + | number + | string + | { [key: string]: EntityPredicateValue }; + +/** + * All conditions must match (AND logic). + * + * @public + */ +export interface EntityPredicateAll { + $all: Array; +} + +/** + * At least one condition must match (OR logic). + * + * @public + */ +export interface EntityPredicateAny { + $any: Array; +} + +/** + * Negates the condition. + * + * @public + */ +export interface EntityPredicateNot { + $not: EntityPredicate; +} + +/** + * Value for a field predicate. + * + * @public + */ +export type EntityPredicateValue = + | EntityPredicateExists + | EntityPredicateIn + | boolean + | number + | string; + +/** + * Check if field exists. + * + * @public + */ +export interface EntityPredicateExists { + $exists: boolean; +} + +/** + * Match any value in array. + * + * @public + */ +export interface EntityPredicateIn { + $in: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index d4c7560c30..fba22d153a 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -350,59 +350,74 @@ components: - type: string - type: number - type: boolean - - type: object - additionalProperties: false - properties: - $all: - type: array - items: - $ref: '#/components/schemas/EntityPredicate' - required: - - $all - - type: object - additionalProperties: false - properties: - $any: - type: array - items: - $ref: '#/components/schemas/EntityPredicate' - required: - - $any - - type: object - additionalProperties: false - properties: - $not: - $ref: '#/components/schemas/EntityPredicate' - required: - - $not + - $ref: '#/components/schemas/EntityPredicateAll' + - $ref: '#/components/schemas/EntityPredicateAny' + - $ref: '#/components/schemas/EntityPredicateNot' - type: object additionalProperties: $ref: '#/components/schemas/EntityPredicateValue' + EntityPredicateAll: + type: object + description: All conditions must match (AND logic) + additionalProperties: false + properties: + $all: + type: array + items: + $ref: '#/components/schemas/EntityPredicate' + required: + - $all + EntityPredicateAny: + type: object + description: At least one condition must match (OR logic) + additionalProperties: false + properties: + $any: + type: array + items: + $ref: '#/components/schemas/EntityPredicate' + required: + - $any + EntityPredicateNot: + type: object + description: Negates the condition + additionalProperties: false + properties: + $not: + $ref: '#/components/schemas/EntityPredicate' + required: + - $not EntityPredicateValue: description: Value for a field predicate oneOf: - type: string - type: number - type: boolean - - type: object - additionalProperties: false - properties: - $exists: - type: boolean - required: - - $exists - - type: object - additionalProperties: false - properties: - $in: - type: array - items: - oneOf: - - type: string - - type: number - - type: boolean - required: - - $in + - $ref: '#/components/schemas/EntityPredicateExists' + - $ref: '#/components/schemas/EntityPredicateIn' + EntityPredicateExists: + type: object + description: Check if field exists + additionalProperties: false + properties: + $exists: + type: boolean + required: + - $exists + EntityPredicateIn: + type: object + description: Match any value in array + additionalProperties: false + properties: + $in: + type: array + items: + oneOf: + - type: string + - type: number + - type: boolean + required: + - $in MapStringString: type: object properties: {} @@ -1160,20 +1175,19 @@ paths: type: string explode: false style: form - /entities/by-predicates: post: - operationId: GetEntitiesByPredicates + operationId: QueryEntitiesByPredicate tags: - Entity description: | - Query entities using predicate-based filters. This endpoint supports a more - expressive filter syntax with logical operators ($all, $any, $not) and - value operators ($exists, $in). + Query entities using predicate-based filters. This endpoint provides an + alternative filtering method with a more expressive filter syntax supporting + logical operators ($all, $any, $not) and value operators ($exists, $in). - Example filter: + Example query: ```json { - "filter": { + "query": { "$all": [ {"kind": "component"}, {"$any": [ @@ -1217,15 +1231,8 @@ paths: parameters: - $ref: '#/components/parameters/limit' - $ref: '#/components/parameters/offset' + - $ref: '#/components/parameters/orderField' - $ref: '#/components/parameters/after' - - name: order - in: query - allowReserved: true - required: false - schema: - type: array - items: - type: string requestBody: required: true content: @@ -1234,18 +1241,18 @@ paths: type: object additionalProperties: false properties: - filter: + query: $ref: '#/components/schemas/EntityPredicate' examples: Get all service components: value: - filter: + query: $all: - kind: component - spec.type: service Get components owned by specific teams: value: - filter: + query: $all: - kind: component - spec.owner: @@ -1254,7 +1261,7 @@ paths: - platform-team Get non-production services: value: - filter: + query: $all: - kind: component - spec.type: service 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 a185f9a359..258c777067 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 @@ -25,9 +25,9 @@ import { EntitiesQueryResponse } from '../models/EntitiesQueryResponse.model'; import { Entity } from '../models/Entity.model'; import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model'; import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model'; -import { GetEntitiesByPredicates200Response } from '../models/GetEntitiesByPredicates200Response.model'; -import { GetEntitiesByPredicatesRequest } from '../models/GetEntitiesByPredicatesRequest.model'; import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model'; +import { QueryEntitiesByPredicate200Response } from '../models/QueryEntitiesByPredicate200Response.model'; +import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model'; import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model'; import { ValidateEntity400Response } from '../models/ValidateEntity400Response.model'; import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model'; @@ -55,19 +55,6 @@ export type GetEntities = { }; response: Array | Error | Error; }; -/** - * @public - */ -export type GetEntitiesByPredicates = { - body: GetEntitiesByPredicatesRequest; - query: { - limit?: number; - offset?: number; - after?: string; - order?: Array; - }; - response: GetEntitiesByPredicates200Response | Error | Error; -}; /** * @public */ @@ -135,6 +122,19 @@ export type GetEntityFacets = { }; response: EntityFacetsResponse | Error | Error; }; +/** + * @public + */ +export type QueryEntitiesByPredicate = { + body: QueryEntitiesByPredicateRequest; + query: { + limit?: number; + offset?: number; + orderField?: Array; + after?: string; + }; + response: QueryEntitiesByPredicate200Response | Error | Error; +}; /** * @public */ @@ -223,8 +223,6 @@ export type EndpointMap = { '#get|/entities': GetEntities; - '#post|/entities/by-predicates': GetEntitiesByPredicates; - '#get|/entities/by-query': GetEntitiesByQuery; '#post|/entities/by-refs': GetEntitiesByRefs; @@ -237,6 +235,8 @@ export type EndpointMap = { '#get|/entity-facets': GetEntityFacets; + '#post|/entities/by-query': QueryEntitiesByPredicate; + '#post|/refresh': RefreshEntity; '#post|/validate-entity': ValidateEntity; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts index 18b9038228..bdc862ca50 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts @@ -17,9 +17,9 @@ // ****************************************************************** // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** -import { EntityPredicateOneOf } from '../models/EntityPredicateOneOf.model'; -import { EntityPredicateOneOf1 } from '../models/EntityPredicateOneOf1.model'; -import { EntityPredicateOneOf2 } from '../models/EntityPredicateOneOf2.model'; +import { EntityPredicateAll } from '../models/EntityPredicateAll.model'; +import { EntityPredicateAny } from '../models/EntityPredicateAny.model'; +import { EntityPredicateNot } from '../models/EntityPredicateNot.model'; import { EntityPredicateValue } from '../models/EntityPredicateValue.model'; /** @@ -27,9 +27,9 @@ import { EntityPredicateValue } from '../models/EntityPredicateValue.model'; * @public */ export type EntityPredicate = - | EntityPredicateOneOf - | EntityPredicateOneOf1 - | EntityPredicateOneOf2 + | EntityPredicateAll + | EntityPredicateAny + | EntityPredicateNot | boolean | number | string diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts new file mode 100644 index 0000000000..080b8307b4 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts @@ -0,0 +1,28 @@ +/* + * 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. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * All conditions must match (AND logic) + * @public + */ +export interface EntityPredicateAll { + $all: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts new file mode 100644 index 0000000000..170ea3ab0c --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts @@ -0,0 +1,28 @@ +/* + * 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. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * At least one condition must match (OR logic) + * @public + */ +export interface EntityPredicateAny { + $any: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts new file mode 100644 index 0000000000..7800fcd20c --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts @@ -0,0 +1,27 @@ +/* + * 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. * +// ****************************************************************** + +/** + * Check if field exists + * @public + */ +export interface EntityPredicateExists { + $exists: boolean; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts new file mode 100644 index 0000000000..574ab4f45b --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts @@ -0,0 +1,28 @@ +/* + * 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. * +// ****************************************************************** +import { EntityPredicateInInInner } from '../models/EntityPredicateInInInner.model'; + +/** + * Match any value in array + * @public + */ +export interface EntityPredicateIn { + $in: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts new file mode 100644 index 0000000000..2b4e3d7c2f --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts @@ -0,0 +1,24 @@ +/* + * 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 type EntityPredicateInInInner = boolean | number | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts new file mode 100644 index 0000000000..e56279b0fc --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts @@ -0,0 +1,28 @@ +/* + * 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. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * Negates the condition + * @public + */ +export interface EntityPredicateNot { + $not: EntityPredicate; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts index 0ea00bef2f..c230c2a984 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts @@ -17,16 +17,16 @@ // ****************************************************************** // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** -import { EntityPredicateValueOneOf } from '../models/EntityPredicateValueOneOf.model'; -import { EntityPredicateValueOneOf1 } from '../models/EntityPredicateValueOneOf1.model'; +import { EntityPredicateExists } from '../models/EntityPredicateExists.model'; +import { EntityPredicateIn } from '../models/EntityPredicateIn.model'; /** * Value for a field predicate * @public */ export type EntityPredicateValue = - | EntityPredicateValueOneOf - | EntityPredicateValueOneOf1 + | EntityPredicateExists + | EntityPredicateIn | boolean | number | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts deleted file mode 100644 index 1bea769175..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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. * -// ****************************************************************** -import { EntityPredicateValueOneOf1InInner } from '../models/EntityPredicateValueOneOf1InInner.model'; - -/** - * @public - */ -export interface EntityPredicateValueOneOf1 { - $in: Array; -} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts new file mode 100644 index 0000000000..3c4b74cd10 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts @@ -0,0 +1,32 @@ +/* + * 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. * +// ****************************************************************** +import { Entity } from '../models/Entity.model'; +import { QueryEntitiesByPredicate200ResponsePageInfo } from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; + +/** + * @public + */ +export interface QueryEntitiesByPredicate200Response { + /** + * The list of entities matching the predicate filter. + */ + items: Array; + pageInfo: QueryEntitiesByPredicate200ResponsePageInfo; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts new file mode 100644 index 0000000000..3ef9f73758 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts @@ -0,0 +1,29 @@ +/* + * 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 QueryEntitiesByPredicate200ResponsePageInfo { + /** + * The cursor for the next batch of entities. + */ + nextCursor?: string; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicatesRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts similarity index 87% rename from plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicatesRequest.model.ts rename to plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index 2937e27df5..8b8276c9da 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicatesRequest.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. @@ -22,6 +22,6 @@ import { EntityPredicate } from '../models/EntityPredicate.model'; /** * @public */ -export interface GetEntitiesByPredicatesRequest { - filter?: EntityPredicate; +export interface QueryEntitiesByPredicateRequest { + query?: EntityPredicate; } 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 abd35ed409..9d7ee33602 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts @@ -32,20 +32,17 @@ export * from '../models/EntityFacetsResponse.model'; export * from '../models/EntityLink.model'; export * from '../models/EntityMeta.model'; export * from '../models/EntityPredicate.model'; -export * from '../models/EntityPredicateOneOf.model'; -export * from '../models/EntityPredicateOneOf1.model'; -export * from '../models/EntityPredicateOneOf2.model'; +export * from '../models/EntityPredicateAll.model'; +export * from '../models/EntityPredicateAny.model'; +export * from '../models/EntityPredicateExists.model'; +export * from '../models/EntityPredicateIn.model'; +export * from '../models/EntityPredicateInInInner.model'; +export * from '../models/EntityPredicateNot.model'; export * from '../models/EntityPredicateValue.model'; -export * from '../models/EntityPredicateValueOneOf.model'; -export * from '../models/EntityPredicateValueOneOf1.model'; -export * from '../models/EntityPredicateValueOneOf1InInner.model'; export * from '../models/EntityRelation.model'; export * from '../models/ErrorError.model'; export * from '../models/ErrorRequest.model'; export * from '../models/ErrorResponse.model'; -export * from '../models/GetEntitiesByPredicates200Response.model'; -export * from '../models/GetEntitiesByPredicates200ResponsePageInfo.model'; -export * from '../models/GetEntitiesByPredicatesRequest.model'; export * from '../models/GetEntitiesByRefsRequest.model'; export * from '../models/GetLocations200ResponseInner.model'; export * from '../models/GetLocationsByQueryRequest.model'; @@ -56,6 +53,9 @@ export * from '../models/LocationsQueryResponse.model'; export * from '../models/LocationsQueryResponsePageInfo.model'; export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; +export * from '../models/QueryEntitiesByPredicate200Response.model'; +export * from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; +export * from '../models/QueryEntitiesByPredicateRequest.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 5fe2c642b7..2625bd0125 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -276,40 +276,13 @@ export const spec = { type: 'boolean', }, { - type: 'object', - additionalProperties: false, - properties: { - $all: { - type: 'array', - items: { - $ref: '#/components/schemas/EntityPredicate', - }, - }, - }, - required: ['$all'], + $ref: '#/components/schemas/EntityPredicateAll', }, { - type: 'object', - additionalProperties: false, - properties: { - $any: { - type: 'array', - items: { - $ref: '#/components/schemas/EntityPredicate', - }, - }, - }, - required: ['$any'], + $ref: '#/components/schemas/EntityPredicateAny', }, { - type: 'object', - additionalProperties: false, - properties: { - $not: { - $ref: '#/components/schemas/EntityPredicate', - }, - }, - required: ['$not'], + $ref: '#/components/schemas/EntityPredicateNot', }, { type: 'object', @@ -319,6 +292,45 @@ export const spec = { }, ], }, + EntityPredicateAll: { + type: 'object', + description: 'All conditions must match (AND logic)', + additionalProperties: false, + properties: { + $all: { + type: 'array', + items: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + }, + required: ['$all'], + }, + EntityPredicateAny: { + type: 'object', + description: 'At least one condition must match (OR logic)', + additionalProperties: false, + properties: { + $any: { + type: 'array', + items: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + }, + required: ['$any'], + }, + EntityPredicateNot: { + type: 'object', + description: 'Negates the condition', + additionalProperties: false, + properties: { + $not: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + required: ['$not'], + }, EntityPredicateValue: { description: 'Value for a field predicate', oneOf: [ @@ -332,40 +344,48 @@ export const spec = { type: 'boolean', }, { - type: 'object', - additionalProperties: false, - properties: { - $exists: { - type: 'boolean', - }, - }, - required: ['$exists'], + $ref: '#/components/schemas/EntityPredicateExists', }, { - type: 'object', - additionalProperties: false, - properties: { - $in: { - type: 'array', - items: { - oneOf: [ - { - type: 'string', - }, - { - type: 'number', - }, - { - type: 'boolean', - }, - ], - }, - }, - }, - required: ['$in'], + $ref: '#/components/schemas/EntityPredicateIn', }, ], }, + EntityPredicateExists: { + type: 'object', + description: 'Check if field exists', + additionalProperties: false, + properties: { + $exists: { + type: 'boolean', + }, + }, + required: ['$exists'], + }, + EntityPredicateIn: { + type: 'object', + description: 'Match any value in array', + additionalProperties: false, + properties: { + $in: { + type: 'array', + items: { + oneOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'boolean', + }, + ], + }, + }, + }, + required: ['$in'], + }, MapStringString: { type: 'object', properties: {}, @@ -1332,13 +1352,11 @@ export const spec = { }, ], }, - }, - '/entities/by-predicates': { post: { - operationId: 'GetEntitiesByPredicates', + operationId: 'QueryEntitiesByPredicate', tags: ['Entity'], description: - 'Query entities using predicate-based filters. This endpoint supports a more\nexpressive filter syntax with logical operators ($all, $any, $not) and\nvalue operators ($exists, $in).\n\nExample filter:\n```json\n{\n "filter": {\n "$all": [\n {"kind": "component"},\n {"$any": [\n {"spec.type": "service"},\n {"spec.type": "website"}\n ]},\n {"$not": {"spec.lifecycle": "experimental"}}\n ]\n }\n}\n```\n', + 'Query entities using predicate-based filters. This endpoint provides an\nalternative filtering method with a more expressive filter syntax supporting\nlogical operators ($all, $any, $not) and value operators ($exists, $in).\n\nExample query:\n```json\n{\n "query": {\n "$all": [\n {"kind": "component"},\n {"$any": [\n {"spec.type": "service"},\n {"spec.type": "website"}\n ]},\n {"$not": {"spec.lifecycle": "experimental"}}\n ]\n }\n}\n```\n', responses: { '200': { description: 'Ok', @@ -1392,19 +1410,10 @@ export const spec = { $ref: '#/components/parameters/offset', }, { - $ref: '#/components/parameters/after', + $ref: '#/components/parameters/orderField', }, { - name: 'order', - in: 'query', - allowReserved: true, - required: false, - schema: { - type: 'array', - items: { - type: 'string', - }, - }, + $ref: '#/components/parameters/after', }, ], requestBody: { @@ -1415,7 +1424,7 @@ export const spec = { type: 'object', additionalProperties: false, properties: { - filter: { + query: { $ref: '#/components/schemas/EntityPredicate', }, }, @@ -1423,7 +1432,7 @@ export const spec = { examples: { 'Get all service components': { value: { - filter: { + query: { $all: [ { kind: 'component', @@ -1437,7 +1446,7 @@ export const spec = { }, 'Get components owned by specific teams': { value: { - filter: { + query: { $all: [ { kind: 'component', @@ -1453,7 +1462,7 @@ export const spec = { }, 'Get non-production services': { value: { - filter: { + query: { $all: [ { kind: 'component', diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index e26a0c1233..6af2fc96b0 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -68,6 +68,7 @@ import { encodeLocationQueryCursor, parseLocationQuery, } from './request/parseLocationQuery'; +import { parseEntityOrderFieldParams } from './request/parseEntityOrderFieldParams'; /** * Options used by {@link createRouter}. @@ -261,24 +262,24 @@ export async function createRouter( throw err; } }) - .post('/entities/by-predicates', async (req, res) => { + .post('/entities/by-query', async (req, res) => { const auditorEvent = await auditor.createEvent({ eventId: 'entity-fetch', request: req, meta: { - queryType: 'by-predicates', + queryType: 'by-query-predicate', }, }); try { - const filter = req.body.filter as EntityPredicate | undefined; - const order = parseEntityOrderParams(req.query); + const query = req.body.query as EntityPredicate | undefined; + const order = parseEntityOrderFieldParams(req.query); const pagination = parseEntityPaginationParams(req.query); const credentials = await httpAuth.credentials(req); const { entities, pageInfo } = await entitiesCatalog.queryEntitiesByPredicate({ - filter, + query, order, pagination, credentials, From 4bc4eb06a0030050b5703d74d7f1aac4314b7c93 Mon Sep 17 00:00:00 2001 From: Nilay1999 Date: Thu, 12 Feb 2026 00:24:32 +0530 Subject: [PATCH 006/118] refactor(catalog): use @backstage/filter-predicates package types - Replace EntityPredicate with FilterPredicate - update package.json and yarn.lock - Add InputError handling for invalid predicate values in filter application - Update catalog-client utils.ts to use browser-compatible atob() for base64 decoding Signed-off-by: Nilay1999 --- packages/catalog-client/src/utils.ts | 3 +- plugins/catalog-backend/src/catalog/types.ts | 9 +-- .../src/service/createRouter.ts | 11 ++-- .../applyPredicateEntityFilterToQuery.ts | 42 +++++++----- plugins/catalog-backend/src/service/util.ts | 5 +- plugins/catalog-node/src/api/common.ts | 66 ------------------- plugins/catalog-node/src/api/index.ts | 4 -- 7 files changed, 42 insertions(+), 98 deletions(-) diff --git a/packages/catalog-client/src/utils.ts b/packages/catalog-client/src/utils.ts index e7713623b9..489093f65d 100644 --- a/packages/catalog-client/src/utils.ts +++ b/packages/catalog-client/src/utils.ts @@ -32,7 +32,8 @@ export function isQueryEntitiesInitialRequest( */ export function cursorContainsQuery(cursor: string): boolean { try { - const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf8')); + // Use browser-compatible base64 decoding + const decoded = JSON.parse(atob(cursor)); return !!decoded.query; } catch { return false; diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index aad176e390..04bfb93d4f 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -16,7 +16,8 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { EntityFilter, EntityPredicate } from '@backstage/plugin-catalog-node'; +import { EntityFilter } from '@backstage/plugin-catalog-node'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * A pagination rule for entities. @@ -53,7 +54,7 @@ export type EntitiesRequest = { }; export type EntityPredicateRequest = { - query?: EntityPredicate; + query?: FilterPredicate; order?: EntityOrder[]; pagination?: EntityPagination; credentials: BackstageCredentials; @@ -232,7 +233,7 @@ export interface QueryEntitiesInitialRequest { * Predicate-based query for filtering entities. * Mutually exclusive with filter. */ - query?: EntityPredicate; + query?: FilterPredicate; orderFields?: EntityOrder[]; fullTextFilter?: { term: string; @@ -298,7 +299,7 @@ export type Cursor = { * A predicate-based query to be applied to the full list of entities. * Mutually exclusive with filter. */ - query?: EntityPredicate; + query?: FilterPredicate; /** * true if the cursor is a previous cursor. */ diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 6af2fc96b0..c3608c19a6 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -30,10 +30,8 @@ import { } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { InputError, serializeError } from '@backstage/errors'; -import { - EntityPredicate, - LocationAnalyzer, -} from '@backstage/plugin-catalog-node'; +import { parseFilterPredicate } from '@backstage/filter-predicates'; +import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; import express from 'express'; import yn from 'yn'; import { z } from 'zod'; @@ -272,7 +270,10 @@ export async function createRouter( }); try { - const query = req.body.query as EntityPredicate | undefined; + // Validate the query using the Zod schema from @backstage/filter-predicates + const query = req.body.query + ? parseFilterPredicate(req.body.query) + : undefined; const order = parseEntityOrderFieldParams(req.query); const pagination = parseEntityPaginationParams(req.query); const credentials = await httpAuth.credentials(req); diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 59e08e7d9e..eff7c12f7e 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -15,32 +15,33 @@ */ import { - EntityPredicate, - EntityPredicatePrimitive, - EntityPredicateValue, -} from '@backstage/plugin-catalog-node'; + FilterPredicate, + FilterPredicatePrimitive, + FilterPredicateValue, +} from '@backstage/filter-predicates'; +import { InputError } from '@backstage/errors'; import { Knex } from 'knex'; import { DbSearchRow } from '../../database/tables'; function isAllPredicate( - filter: EntityPredicate, -): filter is { $all: EntityPredicate[] } { + filter: FilterPredicate, +): filter is { $all: FilterPredicate[] } { return typeof filter === 'object' && filter !== null && '$all' in filter; } function isAnyPredicate( - filter: EntityPredicate, -): filter is { $any: EntityPredicate[] } { + filter: FilterPredicate, +): filter is { $any: FilterPredicate[] } { return typeof filter === 'object' && filter !== null && '$any' in filter; } function isNotPredicate( - filter: EntityPredicate, -): filter is { $not: EntityPredicate } { + filter: FilterPredicate, +): filter is { $not: FilterPredicate } { return typeof filter === 'object' && filter !== null && '$not' in filter; } -function isPrimitive(value: unknown): value is EntityPredicatePrimitive { +function isPrimitive(value: unknown): value is FilterPredicatePrimitive { return ( typeof value === 'string' || typeof value === 'number' || @@ -49,18 +50,18 @@ function isPrimitive(value: unknown): value is EntityPredicatePrimitive { } function isExistsValue( - value: EntityPredicateValue, + value: FilterPredicateValue, ): value is { $exists: boolean } { return typeof value === 'object' && value !== null && '$exists' in value; } function isInValue( - value: EntityPredicateValue, -): value is { $in: EntityPredicatePrimitive[] } { + value: FilterPredicateValue, +): value is { $in: FilterPredicatePrimitive[] } { return typeof value === 'object' && value !== null && '$in' in value; } -function isFieldExpression(filter: EntityPredicate): boolean { +function isFieldExpression(filter: FilterPredicate): boolean { if (typeof filter !== 'object' || filter === null) { return false; } @@ -109,7 +110,7 @@ function isFieldExpression(filter: EntityPredicate): boolean { */ function applyPredicateInStrategy( - filter: EntityPredicate, + filter: FilterPredicate, targetQuery: Knex.QueryBuilder, onEntityIdField: string, knex: Knex, @@ -211,6 +212,13 @@ function applyPredicateInStrategy( value: String(value).toLowerCase(), }); this.andWhere(onEntityIdField, 'in', matchQuery); + } else { + // Reject unsupported/invalid predicate values + throw new InputError( + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, or $in operator, but got ${JSON.stringify( + value, + )}`, + ); } } }, @@ -221,7 +229,7 @@ function applyPredicateInStrategy( } export function applyPredicateEntityFilterToQuery(options: { - filter: EntityPredicate; + filter: FilterPredicate; targetQuery: Knex.QueryBuilder; onEntityIdField: string; knex: Knex; diff --git a/plugins/catalog-backend/src/service/util.ts b/plugins/catalog-backend/src/service/util.ts index dbdbd374cf..708eec2de3 100644 --- a/plugins/catalog-backend/src/service/util.ts +++ b/plugins/catalog-backend/src/service/util.ts @@ -15,6 +15,7 @@ */ import { InputError, NotAllowedError } from '@backstage/errors'; +import { createZodV3FilterPredicateSchema } from '@backstage/filter-predicates'; import { Request } from 'express'; import lodash from 'lodash'; import { z } from 'zod'; @@ -109,6 +110,8 @@ const entityFilterParser: z.ZodSchema = z.lazy(() => .or(z.object({ allOf: z.array(entityFilterParser) })), ); +const filterPredicateSchema = createZodV3FilterPredicateSchema(z); + export const cursorParser: z.ZodSchema = z.object({ orderFields: z.array( z.object({ field: z.string(), order: z.enum(['asc', 'desc']) }), @@ -122,7 +125,7 @@ export const cursorParser: z.ZodSchema = z.object({ orderFieldValues: z.array(z.string().or(z.null())), filter: entityFilterParser.optional(), isPrevious: z.boolean(), - query: z.any().optional(), + query: filterPredicateSchema.optional(), firstSortFieldValues: z.array(z.string().or(z.null())).optional(), totalItems: z.number().optional(), }); diff --git a/plugins/catalog-node/src/api/common.ts b/plugins/catalog-node/src/api/common.ts index f88857a312..7f9bd3eabf 100644 --- a/plugins/catalog-node/src/api/common.ts +++ b/plugins/catalog-node/src/api/common.ts @@ -85,69 +85,3 @@ export type EntitiesSearchFilter = { */ values?: string[]; }; - -/** - * Defines a predicate used to filter entities using logical and comparison operators. - * - * This type supports: - * - Primitive equality matching - * - Field existence checks - * - Inclusion checks - * - Logical composition using $all, $any, and $not - * - * @public - */ -export type EntityPredicate = - | EntityPredicateExpression - | EntityPredicatePrimitive - | { $all: EntityPredicate[] } - | { $any: EntityPredicate[] } - | { $not: EntityPredicate }; - -/** - * Represents a field-based predicate expression. - * - * Each key is treated as a JSON path into the entity object. - * Keys starting with `$` are reserved for operators and are not allowed. - * - * Example: - * ```ts - * { - * "spec.type": "service", - * "metadata.name": { $in: ["payments", "orders"] } - * } - * ``` - * - * @public - */ -export type EntityPredicateExpression = { - /** JSON path of the entity field */ - [KPath in string]: EntityPredicateValue; -} & { - /** Operator keys are not allowed as field paths */ - [KPath in `$${string}`]: never; -}; - -/** - * Represents a value condition for a predicate field. - * - * Supported forms: - * - Primitive equality match - * - Existence check using `$exists` - * - Inclusion check using `$in` - * - * @public - */ -export type EntityPredicateValue = - | EntityPredicatePrimitive - | { $exists: boolean } - | { $in: EntityPredicatePrimitive[] }; - -/** - * Represents a primitive predicate value. - * - * Used for direct equality comparison. - * - * @public - */ -export type EntityPredicatePrimitive = string | number | boolean; diff --git a/plugins/catalog-node/src/api/index.ts b/plugins/catalog-node/src/api/index.ts index 5b7abbb6b9..292a6cd872 100644 --- a/plugins/catalog-node/src/api/index.ts +++ b/plugins/catalog-node/src/api/index.ts @@ -20,10 +20,6 @@ export type { LocationSpec, EntitiesSearchFilter, EntityFilter, - EntityPredicate, - EntityPredicateExpression, - EntityPredicateValue, - EntityPredicatePrimitive, } from './common'; export type { CatalogProcessor, From 51e23eb73e5480c2b4042d9d7a9eb2ff7c4a3979 Mon Sep 17 00:00:00 2001 From: Nilay1999 Date: Thu, 12 Feb 2026 00:32:09 +0530 Subject: [PATCH 007/118] =?UTF-8?q?chore:=20=F0=9F=93=9D=20add=20changeset?= =?UTF-8?q?=20for=20predicate-based=20entity=20querying?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nilay1999 --- .changeset/rich-ducks-ring.md | 15 ++++++++ packages/catalog-client/report.api.md | 51 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .changeset/rich-ducks-ring.md diff --git a/.changeset/rich-ducks-ring.md b/.changeset/rich-ducks-ring.md new file mode 100644 index 0000000000..0ba4b86961 --- /dev/null +++ b/.changeset/rich-ducks-ring.md @@ -0,0 +1,15 @@ +--- +'@backstage/catalog-client': minor +'@backstage/plugin-catalog-backend': minor +--- + +New POST /entities/by-query endpoint + +- Supports predicate-based entity filtering using advanced query operators ($all, $any, $in, $not, $exists) +- Enables complex nested queries for more powerful entity searches +- Provides cursor-based pagination for efficient result traversal + +Updated Catalog Client + +- Enhanced queryEntities() method to automatically route requests to POST endpoint when query predicate is provided +- Validates mutual exclusivity between filter (legacy) and query (predicate-based) parameters diff --git a/packages/catalog-client/report.api.md b/packages/catalog-client/report.api.md index 58fc4bc8a1..10e5b5d684 100644 --- a/packages/catalog-client/report.api.md +++ b/packages/catalog-client/report.api.md @@ -231,6 +231,56 @@ export type EntityOrderQuery = order: 'asc' | 'desc'; }>; +// @public +export type EntityPredicate = + | EntityPredicateAll + | EntityPredicateAny + | EntityPredicateNot + | boolean + | number + | string + | { + [key: string]: EntityPredicateValue; + }; + +// @public +export interface EntityPredicateAll { + // (undocumented) + $all: Array; +} + +// @public +export interface EntityPredicateAny { + // (undocumented) + $any: Array; +} + +// @public +export interface EntityPredicateExists { + // (undocumented) + $exists: boolean; +} + +// @public +export interface EntityPredicateIn { + // (undocumented) + $in: Array; +} + +// @public +export interface EntityPredicateNot { + // (undocumented) + $not: EntityPredicate; +} + +// @public +export type EntityPredicateValue = + | EntityPredicateExists + | EntityPredicateIn + | boolean + | number + | string; + // @public export interface GetEntitiesByRefsRequest { entityRefs: string[]; @@ -320,6 +370,7 @@ export type QueryEntitiesInitialRequest = { limit?: number; offset?: number; filter?: EntityFilterQuery; + query?: EntityPredicate; orderFields?: EntityOrderQuery; fullTextFilter?: { term: string; From 02b33b0650f4b0707dd6c9d3c54ffa231b585511 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Feb 2026 10:10:53 +0100 Subject: [PATCH 008/118] refactor: integrate predicate filtering into queryEntities flow Remove separate queryEntitiesByPredicate method and integrate predicate-based filtering into the existing queryEntities path. POST /entities/by-query now uses the same queryEntities call with proper permission enforcement via applyEntityFilterToQuery. Aligns with the locations query pattern from PR #32846. Signed-off-by: benjdlambert --- .changeset/rich-ducks-ring.md | 11 +- packages/catalog-client/report.api.md | 54 +--- .../catalog-client/src/CatalogClient.test.ts | 43 +-- packages/catalog-client/src/CatalogClient.ts | 70 ++--- .../openapi/generated/apis/Api.client.ts | 19 +- .../generated/models/EntityPredicate.model.ts | 36 --- .../models/EntityPredicateAll.model.ts | 28 -- .../models/EntityPredicateAny.model.ts | 28 -- .../models/EntityPredicateIn.model.ts | 28 -- .../models/EntityPredicateInInInner.model.ts | 24 -- .../models/EntityPredicateNot.model.ts | 28 -- .../models/EntityPredicateValue.model.ts | 32 --- ...eryEntitiesByPredicate200Response.model.ts | 32 --- ...iesByPredicate200ResponsePageInfo.model.ts | 29 -- .../QueryEntitiesByPredicateRequest.model.ts | 13 +- ...sByPredicateRequestFullTextFilter.model.ts | 6 +- .../schema/openapi/generated/models/index.ts | 11 +- packages/catalog-client/src/types/api.ts | 2 +- packages/catalog-client/src/types/index.ts | 9 - .../catalog-client/src/types/predicate.ts | 122 --------- packages/catalog-client/src/utils.ts | 3 +- plugins/catalog-backend/src/catalog/types.ts | 16 -- .../catalog-backend/src/schema/openapi.yaml | 152 ++--------- .../openapi/generated/apis/Api.server.ts | 9 +- .../generated/models/EntityPredicate.model.ts | 36 --- .../models/EntityPredicateAll.model.ts | 28 -- .../models/EntityPredicateAny.model.ts | 28 -- .../models/EntityPredicateIn.model.ts | 28 -- .../models/EntityPredicateInInInner.model.ts | 24 -- .../models/EntityPredicateNot.model.ts | 28 -- .../models/EntityPredicateValue.model.ts | 32 --- ...eryEntitiesByPredicate200Response.model.ts | 32 --- ...iesByPredicate200ResponsePageInfo.model.ts | 29 -- .../QueryEntitiesByPredicateRequest.model.ts | 13 +- ...sByPredicateRequestFullTextFilter.model.ts | 6 +- .../schema/openapi/generated/models/index.ts | 11 +- .../src/schema/openapi/generated/router.ts | 249 +++--------------- .../service/AuthorizedEntitiesCatalog.test.ts | 1 - .../src/service/AuthorizedEntitiesCatalog.ts | 31 --- .../src/service/DefaultEntitiesCatalog.ts | 159 +---------- .../src/service/createRouter.test.ts | 62 ++++- .../src/service/createRouter.ts | 40 +-- .../request/applyEntityFilterToQuery.test.ts | 3 +- .../request/applyEntityFilterToQuery.ts | 25 +- .../applyPredicateEntityFilterToQuery.ts | 16 +- .../src/service/request/parseEntityQuery.ts | 115 ++++++++ 46 files changed, 364 insertions(+), 1437 deletions(-) delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts rename plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts => packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts (88%) delete mode 100644 packages/catalog-client/src/types/predicate.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts rename packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts => plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts (88%) create mode 100644 plugins/catalog-backend/src/service/request/parseEntityQuery.ts diff --git a/.changeset/rich-ducks-ring.md b/.changeset/rich-ducks-ring.md index 0ba4b86961..04848da4a7 100644 --- a/.changeset/rich-ducks-ring.md +++ b/.changeset/rich-ducks-ring.md @@ -3,13 +3,8 @@ '@backstage/plugin-catalog-backend': minor --- -New POST /entities/by-query endpoint +Added predicate-based entity filtering via POST /entities/by-query endpoint. -- Supports predicate-based entity filtering using advanced query operators ($all, $any, $in, $not, $exists) -- Enables complex nested queries for more powerful entity searches -- Provides cursor-based pagination for efficient result traversal +Supports `$all`, `$any`, `$not`, `$exists`, and `$in` operators for expressive entity queries. Integrated into the existing `queryEntities` flow with full cursor-based pagination, permission enforcement, and `totalItems` support. -Updated Catalog Client - -- Enhanced queryEntities() method to automatically route requests to POST endpoint when query predicate is provided -- Validates mutual exclusivity between filter (legacy) and query (predicate-based) parameters +The catalog client's `queryEntities()` method automatically routes to the POST endpoint when a `query` predicate is provided. diff --git a/packages/catalog-client/report.api.md b/packages/catalog-client/report.api.md index 10e5b5d684..cc83cce443 100644 --- a/packages/catalog-client/report.api.md +++ b/packages/catalog-client/report.api.md @@ -7,7 +7,7 @@ import type { AnalyzeLocationRequest } from '@backstage/plugin-catalog-common'; import type { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; -import { FilterPredicate } from '@backstage/filter-predicates'; +import type { FilterPredicate } from '@backstage/filter-predicates'; import { SerializedError } from '@backstage/errors'; // @public @@ -231,56 +231,6 @@ export type EntityOrderQuery = order: 'asc' | 'desc'; }>; -// @public -export type EntityPredicate = - | EntityPredicateAll - | EntityPredicateAny - | EntityPredicateNot - | boolean - | number - | string - | { - [key: string]: EntityPredicateValue; - }; - -// @public -export interface EntityPredicateAll { - // (undocumented) - $all: Array; -} - -// @public -export interface EntityPredicateAny { - // (undocumented) - $any: Array; -} - -// @public -export interface EntityPredicateExists { - // (undocumented) - $exists: boolean; -} - -// @public -export interface EntityPredicateIn { - // (undocumented) - $in: Array; -} - -// @public -export interface EntityPredicateNot { - // (undocumented) - $not: EntityPredicate; -} - -// @public -export type EntityPredicateValue = - | EntityPredicateExists - | EntityPredicateIn - | boolean - | number - | string; - // @public export interface GetEntitiesByRefsRequest { entityRefs: string[]; @@ -370,7 +320,7 @@ export type QueryEntitiesInitialRequest = { limit?: number; offset?: number; filter?: EntityFilterQuery; - query?: EntityPredicate; + query?: FilterPredicate; orderFields?: EntityOrderQuery; fullTextFilter?: { term: string; diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 31c5d62046..4ee93c0cba 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -568,6 +568,7 @@ describe('CatalogClient', () => { }, }, ], + totalItems: 2, pageInfo: {}, }; @@ -575,9 +576,8 @@ describe('CatalogClient', () => { const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { expect(req.method).toBe('POST'); expect(req.body).toMatchObject({ - query: { - kind: 'component', - }, + query: { kind: 'component' }, + limit: 20, }); return res(ctx.json(defaultResponse)); }); @@ -591,7 +591,7 @@ describe('CatalogClient', () => { expect(mockedEndpoint).toHaveBeenCalledTimes(1); expect(response.items).toEqual(defaultResponse.items); - expect(response.totalItems).toBe(defaultResponse.items.length); + expect(response.totalItems).toBe(2); }); it('should throw error when both filter and query are provided', async () => { @@ -753,10 +753,7 @@ describe('CatalogClient', () => { it('should send orderFields with correct format (field,order)', async () => { const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { - const url = new URL(req.url); - expect(url.searchParams.getAll('orderField')).toEqual([ - 'metadata.name,asc', - ]); + expect(req.body.orderField).toEqual(['metadata.name,asc']); return res(ctx.json(defaultResponse)); }); @@ -772,8 +769,7 @@ describe('CatalogClient', () => { it('should send multiple orderFields with correct format', async () => { const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { - const url = new URL(req.url); - expect(url.searchParams.getAll('orderField')).toEqual([ + expect(req.body.orderField).toEqual([ 'metadata.name,asc', 'spec.type,desc', ]); @@ -793,11 +789,9 @@ describe('CatalogClient', () => { expect(mockedEndpoint).toHaveBeenCalledTimes(1); }); - it('should send limit and offset parameters', async () => { + it('should send limit and offset parameters in the body', async () => { const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { - const url = new URL(req.url); - expect(url.searchParams.get('limit')).toBe('50'); - expect(url.searchParams.get('offset')).toBe('10'); + expect(req.body.limit).toBe(50); return res(ctx.json(defaultResponse)); }); @@ -806,32 +800,11 @@ describe('CatalogClient', () => { await client.queryEntities({ query: { kind: 'component' }, limit: 50, - offset: 10, }); expect(mockedEndpoint).toHaveBeenCalledTimes(1); }); - it('should not allow cursor with query (cursor takes precedence)', async () => { - // When cursor is provided, it's not an initial request, so the query - // parameter is ignored and it goes to GET endpoint - const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { - // Should use GET endpoint, not POST - expect(req.method).toBe('GET'); - return res(ctx.json({ items: [], totalItems: 0, pageInfo: {} })); - }); - - server.use(rest.get(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); - - // This will use GET endpoint with cursor, ignoring the query parameter - await client.queryEntities({ - cursor: 'some-cursor', - query: { kind: 'component' }, - } as any); - - expect(mockedEndpoint).toHaveBeenCalledTimes(1); - }); - it('should handle errors from POST endpoint', async () => { const mockedEndpoint = jest .fn() diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 2d7fa7d957..51d57f715e 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -54,6 +54,7 @@ import { import { DefaultApiClient, GetLocationsByQueryRequest, + QueryEntitiesByPredicateRequest, TypedResponse, } from './schema/openapi'; import type { @@ -356,61 +357,48 @@ export class CatalogClient implements CatalogApi { request: QueryEntitiesRequest, options?: CatalogRequestOptions, ): Promise { - const params: { - limit?: number; - offset?: number; - after?: string; - orderField?: string[]; - cursor?: string; - fields?: string[]; - } = {}; - - let query; + const body: Record = {}; if (isQueryEntitiesInitialRequest(request)) { - // Initial request with query predicate - const { query: requestQuery, limit, offset, orderFields } = request; - query = requestQuery; - - if (limit !== undefined) { - params.limit = limit; + const { query, limit, orderFields, fullTextFilter, fields } = request; + if (query && typeof query === 'object') { + body.query = query; } - if (offset !== undefined) { - params.offset = offset; + if (limit !== undefined) { + body.limit = limit; } if (orderFields !== undefined) { - params.orderField = ( + body.orderField = ( Array.isArray(orderFields) ? orderFields : [orderFields] ).map(({ field, order }) => `${field},${order}`); } - } else { - // Cursor-based pagination request - const { cursor, limit } = request; - - params.after = cursor; - if (limit !== undefined) { - params.limit = limit; + if (fullTextFilter) { + body.fullTextFilter = fullTextFilter; + } + if (fields?.length) { + body.fields = fields; + } + } else { + body.cursor = request.cursor; + if (request.limit !== undefined) { + body.limit = request.limit; + } + if (request.fields?.length) { + body.fields = request.fields; } - // Query will be extracted from cursor on the backend - query = undefined; } - const response = await this.apiClient.queryEntitiesByPredicate( - { - body: query ? { query } : {}, - query: params, - }, - options, + const res = await this.requestRequired( + await this.apiClient.queryEntitiesByPredicate( + { body: body as unknown as QueryEntitiesByPredicateRequest }, + options, + ), ); - const result = await this.requestRequired(response); - return { - items: result.items, - totalItems: result.items.length, - pageInfo: { - nextCursor: result.pageInfo?.nextCursor, - }, + items: res.items, + totalItems: res.totalItems, + pageInfo: res.pageInfo, }; } 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 bc34d1c769..5a5eb2ec5d 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 @@ -29,7 +29,6 @@ import { Entity } from '../models/Entity.model'; import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model'; import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model'; import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model'; -import { QueryEntitiesByPredicate200Response } from '../models/QueryEntitiesByPredicate200Response.model'; import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model'; import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model'; import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model'; @@ -146,12 +145,6 @@ export type GetEntityFacets = { */ export type QueryEntitiesByPredicate = { body: QueryEntitiesByPredicateRequest; - query: { - limit?: number; - offset?: number; - orderField?: Array; - after?: string; - }; }; /** * @public @@ -466,23 +459,17 @@ export class DefaultApiClient { /** * Query entities using predicate-based filters. This endpoint provides an alternative filtering method with a more expressive filter syntax supporting logical operators ($all, $any, $not) and value operators ($exists, $in). Example query: ```json { \"query\": { \"$all\": [ {\"kind\": \"component\"}, {\"$any\": [ {\"spec.type\": \"service\"}, {\"spec.type\": \"website\"} ]}, {\"$not\": {\"spec.lifecycle\": \"experimental\"}} ] } } ``` * @param queryEntitiesByPredicateRequest - - * @param limit - Number of records to return in the response. - * @param offset - Number of records to skip in the query page. - * @param orderField - By default the entities are returned ordered by their internal uid. You can customize the `orderField` query parameters to affect that ordering. For example, to return entities by their name: `/entities/by-query?orderField=metadata.name,asc` Each parameter can be followed by `asc` for ascending lexicographical order or `desc` for descending (reverse) lexicographical order. - * @param after - Pointer to the previous page of results. */ public async queryEntitiesByPredicate( // @ts-ignore request: QueryEntitiesByPredicate, options?: RequestOptions, - ): Promise> { + ): Promise> { const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); - const uriTemplate = `/entities/by-query{?limit,offset,orderField*,after}`; + const uriTemplate = `/entities/by-query`; - const uri = parser.parse(uriTemplate).expand({ - ...request.query, - }); + const uri = parser.parse(uriTemplate).expand({}); return await this.fetchApi.fetch(`${baseUrl}${uri}`, { headers: { diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts deleted file mode 100644 index bdc862ca50..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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. * -// ****************************************************************** -import { EntityPredicateAll } from '../models/EntityPredicateAll.model'; -import { EntityPredicateAny } from '../models/EntityPredicateAny.model'; -import { EntityPredicateNot } from '../models/EntityPredicateNot.model'; -import { EntityPredicateValue } from '../models/EntityPredicateValue.model'; - -/** - * A predicate-based filter supporting logical operators. - $all: All conditions must match (AND) - $any: At least one condition must match (OR) - $not: Negates the condition - $exists: Check if field exists - $in: Match any value in array - * @public - */ -export type EntityPredicate = - | EntityPredicateAll - | EntityPredicateAny - | EntityPredicateNot - | boolean - | number - | string - | { [key: string]: EntityPredicateValue }; diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts deleted file mode 100644 index 080b8307b4..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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. * -// ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; - -/** - * All conditions must match (AND logic) - * @public - */ -export interface EntityPredicateAll { - $all: Array; -} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts deleted file mode 100644 index 170ea3ab0c..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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. * -// ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; - -/** - * At least one condition must match (OR logic) - * @public - */ -export interface EntityPredicateAny { - $any: Array; -} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts deleted file mode 100644 index 574ab4f45b..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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. * -// ****************************************************************** -import { EntityPredicateInInInner } from '../models/EntityPredicateInInInner.model'; - -/** - * Match any value in array - * @public - */ -export interface EntityPredicateIn { - $in: Array; -} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts deleted file mode 100644 index 2b4e3d7c2f..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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 type EntityPredicateInInInner = boolean | number | string; diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts deleted file mode 100644 index e56279b0fc..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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. * -// ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; - -/** - * Negates the condition - * @public - */ -export interface EntityPredicateNot { - $not: EntityPredicate; -} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts deleted file mode 100644 index c230c2a984..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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. * -// ****************************************************************** -import { EntityPredicateExists } from '../models/EntityPredicateExists.model'; -import { EntityPredicateIn } from '../models/EntityPredicateIn.model'; - -/** - * Value for a field predicate - * @public - */ -export type EntityPredicateValue = - | EntityPredicateExists - | EntityPredicateIn - | boolean - | number - | string; diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts deleted file mode 100644 index 3c4b74cd10..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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. * -// ****************************************************************** -import { Entity } from '../models/Entity.model'; -import { QueryEntitiesByPredicate200ResponsePageInfo } from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; - -/** - * @public - */ -export interface QueryEntitiesByPredicate200Response { - /** - * The list of entities matching the predicate filter. - */ - items: Array; - pageInfo: QueryEntitiesByPredicate200ResponsePageInfo; -} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts deleted file mode 100644 index 3ef9f73758..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 QueryEntitiesByPredicate200ResponsePageInfo { - /** - * The cursor for the next batch of entities. - */ - nextCursor?: string; -} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index 8b8276c9da..bdcd854a5c 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -17,11 +17,20 @@ // ****************************************************************** // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; + +import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; /** * @public */ export interface QueryEntitiesByPredicateRequest { - query?: EntityPredicate; + cursor?: string; + limit?: number; + orderField?: Array; + fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter; + fields?: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; } diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts similarity index 88% rename from plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts index 7800fcd20c..e7e971e1ea 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts @@ -19,9 +19,9 @@ // ****************************************************************** /** - * Check if field exists * @public */ -export interface EntityPredicateExists { - $exists: boolean; +export interface QueryEntitiesByPredicateRequestFullTextFilter { + term?: string; + fields?: Array; } 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 9d7ee33602..889b5968fa 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/index.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/index.ts @@ -31,14 +31,6 @@ export * from '../models/EntityFacet.model'; export * from '../models/EntityFacetsResponse.model'; export * from '../models/EntityLink.model'; export * from '../models/EntityMeta.model'; -export * from '../models/EntityPredicate.model'; -export * from '../models/EntityPredicateAll.model'; -export * from '../models/EntityPredicateAny.model'; -export * from '../models/EntityPredicateExists.model'; -export * from '../models/EntityPredicateIn.model'; -export * from '../models/EntityPredicateInInInner.model'; -export * from '../models/EntityPredicateNot.model'; -export * from '../models/EntityPredicateValue.model'; export * from '../models/EntityRelation.model'; export * from '../models/ErrorError.model'; export * from '../models/ErrorRequest.model'; @@ -53,9 +45,8 @@ export * from '../models/LocationsQueryResponse.model'; export * from '../models/LocationsQueryResponsePageInfo.model'; export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; -export * from '../models/QueryEntitiesByPredicate200Response.model'; -export * from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; export * from '../models/QueryEntitiesByPredicateRequest.model'; +export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; export * from '../models/RecursivePartialEntity.model'; export * from '../models/RecursivePartialEntityMeta.model'; export * from '../models/RecursivePartialEntityMetaAllOf.model'; diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index dda003ec90..8bf13271b2 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -20,7 +20,7 @@ import type { AnalyzeLocationRequest, AnalyzeLocationResponse, } from '@backstage/plugin-catalog-common'; -import { FilterPredicate } from '@backstage/filter-predicates'; +import type { FilterPredicate } from '@backstage/filter-predicates'; /** * This symbol can be used in place of a value when passed to filters in e.g. diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts index 20a5f3303d..b280f1874f 100644 --- a/packages/catalog-client/src/types/index.ts +++ b/packages/catalog-client/src/types/index.ts @@ -44,13 +44,4 @@ export type { QueryLocationsInitialRequest, QueryLocationsResponse, } from './api'; -export type { - EntityPredicate, - EntityPredicateAll, - EntityPredicateAny, - EntityPredicateNot, - EntityPredicateValue, - EntityPredicateExists, - EntityPredicateIn, -} from './predicate'; export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status'; diff --git a/packages/catalog-client/src/types/predicate.ts b/packages/catalog-client/src/types/predicate.ts deleted file mode 100644 index 11b081d650..0000000000 --- a/packages/catalog-client/src/types/predicate.ts +++ /dev/null @@ -1,122 +0,0 @@ -/* - * 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. - */ - -/** - * A predicate-based filter supporting logical operators. - * - * @remarks - * - * This provides a more expressive filter syntax compared to the traditional - * EntityFilterQuery. It supports: - * - Logical operators: $all (AND), $any (OR), $not (negation) - * - Value operators: $exists, $in - * - Direct field matching with primitive values - * - * @example - * ```typescript - * // Match all service components - * { - * $all: [ - * { kind: 'component' }, - * { 'spec.type': 'service' } - * ] - * } - * - * // Match components owned by specific teams - * { - * $all: [ - * { kind: 'component' }, - * { 'spec.owner': { $in: ['backend-team', 'platform-team'] } } - * ] - * } - * - * // Match non-production services - * { - * $all: [ - * { kind: 'component' }, - * { 'spec.type': 'service' }, - * { $not: { 'spec.lifecycle': 'production' } } - * ] - * } - * ``` - * - * @public - */ -export type EntityPredicate = - | EntityPredicateAll - | EntityPredicateAny - | EntityPredicateNot - | boolean - | number - | string - | { [key: string]: EntityPredicateValue }; - -/** - * All conditions must match (AND logic). - * - * @public - */ -export interface EntityPredicateAll { - $all: Array; -} - -/** - * At least one condition must match (OR logic). - * - * @public - */ -export interface EntityPredicateAny { - $any: Array; -} - -/** - * Negates the condition. - * - * @public - */ -export interface EntityPredicateNot { - $not: EntityPredicate; -} - -/** - * Value for a field predicate. - * - * @public - */ -export type EntityPredicateValue = - | EntityPredicateExists - | EntityPredicateIn - | boolean - | number - | string; - -/** - * Check if field exists. - * - * @public - */ -export interface EntityPredicateExists { - $exists: boolean; -} - -/** - * Match any value in array. - * - * @public - */ -export interface EntityPredicateIn { - $in: Array; -} diff --git a/packages/catalog-client/src/utils.ts b/packages/catalog-client/src/utils.ts index 489093f65d..fc867d8670 100644 --- a/packages/catalog-client/src/utils.ts +++ b/packages/catalog-client/src/utils.ts @@ -32,9 +32,8 @@ export function isQueryEntitiesInitialRequest( */ export function cursorContainsQuery(cursor: string): boolean { try { - // Use browser-compatible base64 decoding const decoded = JSON.parse(atob(cursor)); - return !!decoded.query; + return 'query' in decoded; } catch { return false; } diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 04bfb93d4f..dda7e9a811 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -53,13 +53,6 @@ export type EntitiesRequest = { credentials: BackstageCredentials; }; -export type EntityPredicateRequest = { - query?: FilterPredicate; - order?: EntityOrder[]; - pagination?: EntityPagination; - credentials: BackstageCredentials; -}; - /** * Encapsulates either a deserialized or serialized entities to be sent in a response. * @internal @@ -173,15 +166,6 @@ export interface EntitiesCatalog { */ queryEntities(request: QueryEntitiesRequest): Promise; - /** - * Fetch entities using predicate-based filters. - * - * @param request - Request options with predicate filter - */ - queryEntitiesByPredicate( - request?: EntityPredicateRequest, - ): Promise; - /** * Removes a single entity. * diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index fba22d153a..f6b80ca062 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -338,86 +338,6 @@ components: properties: {} description: A type representing all allowed JSON object values. additionalProperties: {} - EntityPredicate: - description: | - A predicate-based filter supporting logical operators. - - $all: All conditions must match (AND) - - $any: At least one condition must match (OR) - - $not: Negates the condition - - $exists: Check if field exists - - $in: Match any value in array - oneOf: - - type: string - - type: number - - type: boolean - - $ref: '#/components/schemas/EntityPredicateAll' - - $ref: '#/components/schemas/EntityPredicateAny' - - $ref: '#/components/schemas/EntityPredicateNot' - - type: object - additionalProperties: - $ref: '#/components/schemas/EntityPredicateValue' - EntityPredicateAll: - type: object - description: All conditions must match (AND logic) - additionalProperties: false - properties: - $all: - type: array - items: - $ref: '#/components/schemas/EntityPredicate' - required: - - $all - EntityPredicateAny: - type: object - description: At least one condition must match (OR logic) - additionalProperties: false - properties: - $any: - type: array - items: - $ref: '#/components/schemas/EntityPredicate' - required: - - $any - EntityPredicateNot: - type: object - description: Negates the condition - additionalProperties: false - properties: - $not: - $ref: '#/components/schemas/EntityPredicate' - required: - - $not - EntityPredicateValue: - description: Value for a field predicate - oneOf: - - type: string - - type: number - - type: boolean - - $ref: '#/components/schemas/EntityPredicateExists' - - $ref: '#/components/schemas/EntityPredicateIn' - EntityPredicateExists: - type: object - description: Check if field exists - additionalProperties: false - properties: - $exists: - type: boolean - required: - - $exists - EntityPredicateIn: - type: object - description: Match any value in array - additionalProperties: false - properties: - $in: - type: array - items: - oneOf: - - type: string - - type: number - - type: boolean - required: - - $in MapStringString: type: object properties: {} @@ -1205,22 +1125,7 @@ paths: content: application/json: schema: - type: object - properties: - items: - type: array - items: - $ref: '#/components/schemas/Entity' - description: The list of entities matching the predicate filter. - pageInfo: - type: object - properties: - nextCursor: - type: string - description: The cursor for the next batch of entities. - required: - - items - - pageInfo + $ref: '#/components/schemas/EntitiesQueryResponse' '400': $ref: '#/components/responses/ErrorResponse' default: @@ -1228,45 +1133,36 @@ paths: security: - {} - JWT: [] - parameters: - - $ref: '#/components/parameters/limit' - - $ref: '#/components/parameters/offset' - - $ref: '#/components/parameters/orderField' - - $ref: '#/components/parameters/after' requestBody: - required: true + required: false content: application/json: schema: type: object - additionalProperties: false properties: + cursor: + type: string + limit: + type: number + orderField: + type: array + items: + type: string + fullTextFilter: + type: object + properties: + term: + type: string + fields: + type: array + items: + type: string + fields: + type: array + items: + type: string query: - $ref: '#/components/schemas/EntityPredicate' - examples: - Get all service components: - value: - query: - $all: - - kind: component - - spec.type: service - Get components owned by specific teams: - value: - query: - $all: - - kind: component - - spec.owner: - $in: - - backend-team - - platform-team - Get non-production services: - value: - query: - $all: - - kind: component - - spec.type: service - - $not: - spec.lifecycle: production + $ref: '#/components/schemas/JsonObject' /entity-facets: get: operationId: GetEntityFacets 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 258c777067..dfb111359c 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 @@ -26,7 +26,6 @@ import { Entity } from '../models/Entity.model'; import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model'; import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model'; import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model'; -import { QueryEntitiesByPredicate200Response } from '../models/QueryEntitiesByPredicate200Response.model'; import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model'; import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model'; import { ValidateEntity400Response } from '../models/ValidateEntity400Response.model'; @@ -127,13 +126,7 @@ export type GetEntityFacets = { */ export type QueryEntitiesByPredicate = { body: QueryEntitiesByPredicateRequest; - query: { - limit?: number; - offset?: number; - orderField?: Array; - after?: string; - }; - response: QueryEntitiesByPredicate200Response | Error | Error; + response: EntitiesQueryResponse | Error | Error; }; /** * @public diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts deleted file mode 100644 index bdc862ca50..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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. * -// ****************************************************************** -import { EntityPredicateAll } from '../models/EntityPredicateAll.model'; -import { EntityPredicateAny } from '../models/EntityPredicateAny.model'; -import { EntityPredicateNot } from '../models/EntityPredicateNot.model'; -import { EntityPredicateValue } from '../models/EntityPredicateValue.model'; - -/** - * A predicate-based filter supporting logical operators. - $all: All conditions must match (AND) - $any: At least one condition must match (OR) - $not: Negates the condition - $exists: Check if field exists - $in: Match any value in array - * @public - */ -export type EntityPredicate = - | EntityPredicateAll - | EntityPredicateAny - | EntityPredicateNot - | boolean - | number - | string - | { [key: string]: EntityPredicateValue }; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts deleted file mode 100644 index 080b8307b4..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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. * -// ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; - -/** - * All conditions must match (AND logic) - * @public - */ -export interface EntityPredicateAll { - $all: Array; -} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts deleted file mode 100644 index 170ea3ab0c..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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. * -// ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; - -/** - * At least one condition must match (OR logic) - * @public - */ -export interface EntityPredicateAny { - $any: Array; -} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts deleted file mode 100644 index 574ab4f45b..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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. * -// ****************************************************************** -import { EntityPredicateInInInner } from '../models/EntityPredicateInInInner.model'; - -/** - * Match any value in array - * @public - */ -export interface EntityPredicateIn { - $in: Array; -} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts deleted file mode 100644 index 2b4e3d7c2f..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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 type EntityPredicateInInInner = boolean | number | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts deleted file mode 100644 index e56279b0fc..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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. * -// ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; - -/** - * Negates the condition - * @public - */ -export interface EntityPredicateNot { - $not: EntityPredicate; -} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts deleted file mode 100644 index c230c2a984..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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. * -// ****************************************************************** -import { EntityPredicateExists } from '../models/EntityPredicateExists.model'; -import { EntityPredicateIn } from '../models/EntityPredicateIn.model'; - -/** - * Value for a field predicate - * @public - */ -export type EntityPredicateValue = - | EntityPredicateExists - | EntityPredicateIn - | boolean - | number - | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts deleted file mode 100644 index 3c4b74cd10..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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. * -// ****************************************************************** -import { Entity } from '../models/Entity.model'; -import { QueryEntitiesByPredicate200ResponsePageInfo } from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; - -/** - * @public - */ -export interface QueryEntitiesByPredicate200Response { - /** - * The list of entities matching the predicate filter. - */ - items: Array; - pageInfo: QueryEntitiesByPredicate200ResponsePageInfo; -} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts deleted file mode 100644 index 3ef9f73758..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 QueryEntitiesByPredicate200ResponsePageInfo { - /** - * The cursor for the next batch of entities. - */ - nextCursor?: string; -} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index 8b8276c9da..bdcd854a5c 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -17,11 +17,20 @@ // ****************************************************************** // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; + +import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; /** * @public */ export interface QueryEntitiesByPredicateRequest { - query?: EntityPredicate; + cursor?: string; + limit?: number; + orderField?: Array; + fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter; + fields?: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; } diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts similarity index 88% rename from packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts rename to plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts index 7800fcd20c..e7e971e1ea 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts @@ -19,9 +19,9 @@ // ****************************************************************** /** - * Check if field exists * @public */ -export interface EntityPredicateExists { - $exists: boolean; +export interface QueryEntitiesByPredicateRequestFullTextFilter { + term?: string; + fields?: Array; } 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 9d7ee33602..889b5968fa 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts @@ -31,14 +31,6 @@ export * from '../models/EntityFacet.model'; export * from '../models/EntityFacetsResponse.model'; export * from '../models/EntityLink.model'; export * from '../models/EntityMeta.model'; -export * from '../models/EntityPredicate.model'; -export * from '../models/EntityPredicateAll.model'; -export * from '../models/EntityPredicateAny.model'; -export * from '../models/EntityPredicateExists.model'; -export * from '../models/EntityPredicateIn.model'; -export * from '../models/EntityPredicateInInInner.model'; -export * from '../models/EntityPredicateNot.model'; -export * from '../models/EntityPredicateValue.model'; export * from '../models/EntityRelation.model'; export * from '../models/ErrorError.model'; export * from '../models/ErrorRequest.model'; @@ -53,9 +45,8 @@ export * from '../models/LocationsQueryResponse.model'; export * from '../models/LocationsQueryResponsePageInfo.model'; export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; -export * from '../models/QueryEntitiesByPredicate200Response.model'; -export * from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; export * from '../models/QueryEntitiesByPredicateRequest.model'; +export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.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 2625bd0125..afac4ce5f8 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -262,130 +262,6 @@ export const spec = { description: 'A type representing all allowed JSON object values.', additionalProperties: {}, }, - EntityPredicate: { - description: - 'A predicate-based filter supporting logical operators.\n- $all: All conditions must match (AND)\n- $any: At least one condition must match (OR)\n- $not: Negates the condition\n- $exists: Check if field exists\n- $in: Match any value in array\n', - oneOf: [ - { - type: 'string', - }, - { - type: 'number', - }, - { - type: 'boolean', - }, - { - $ref: '#/components/schemas/EntityPredicateAll', - }, - { - $ref: '#/components/schemas/EntityPredicateAny', - }, - { - $ref: '#/components/schemas/EntityPredicateNot', - }, - { - type: 'object', - additionalProperties: { - $ref: '#/components/schemas/EntityPredicateValue', - }, - }, - ], - }, - EntityPredicateAll: { - type: 'object', - description: 'All conditions must match (AND logic)', - additionalProperties: false, - properties: { - $all: { - type: 'array', - items: { - $ref: '#/components/schemas/EntityPredicate', - }, - }, - }, - required: ['$all'], - }, - EntityPredicateAny: { - type: 'object', - description: 'At least one condition must match (OR logic)', - additionalProperties: false, - properties: { - $any: { - type: 'array', - items: { - $ref: '#/components/schemas/EntityPredicate', - }, - }, - }, - required: ['$any'], - }, - EntityPredicateNot: { - type: 'object', - description: 'Negates the condition', - additionalProperties: false, - properties: { - $not: { - $ref: '#/components/schemas/EntityPredicate', - }, - }, - required: ['$not'], - }, - EntityPredicateValue: { - description: 'Value for a field predicate', - oneOf: [ - { - type: 'string', - }, - { - type: 'number', - }, - { - type: 'boolean', - }, - { - $ref: '#/components/schemas/EntityPredicateExists', - }, - { - $ref: '#/components/schemas/EntityPredicateIn', - }, - ], - }, - EntityPredicateExists: { - type: 'object', - description: 'Check if field exists', - additionalProperties: false, - properties: { - $exists: { - type: 'boolean', - }, - }, - required: ['$exists'], - }, - EntityPredicateIn: { - type: 'object', - description: 'Match any value in array', - additionalProperties: false, - properties: { - $in: { - type: 'array', - items: { - oneOf: [ - { - type: 'string', - }, - { - type: 'number', - }, - { - type: 'boolean', - }, - ], - }, - }, - }, - required: ['$in'], - }, MapStringString: { type: 'object', properties: {}, @@ -1363,28 +1239,7 @@ export const spec = { content: { 'application/json': { schema: { - type: 'object', - properties: { - items: { - type: 'array', - items: { - $ref: '#/components/schemas/Entity', - }, - description: - 'The list of entities matching the predicate filter.', - }, - pageInfo: { - type: 'object', - properties: { - nextCursor: { - type: 'string', - description: - 'The cursor for the next batch of entities.', - }, - }, - }, - }, - required: ['items', 'pageInfo'], + $ref: '#/components/schemas/EntitiesQueryResponse', }, }, }, @@ -1402,81 +1257,47 @@ export const spec = { JWT: [], }, ], - parameters: [ - { - $ref: '#/components/parameters/limit', - }, - { - $ref: '#/components/parameters/offset', - }, - { - $ref: '#/components/parameters/orderField', - }, - { - $ref: '#/components/parameters/after', - }, - ], requestBody: { - required: true, + required: false, content: { 'application/json': { schema: { type: 'object', - additionalProperties: false, properties: { + cursor: { + type: 'string', + }, + limit: { + type: 'number', + }, + orderField: { + type: 'array', + items: { + type: 'string', + }, + }, + fullTextFilter: { + type: 'object', + properties: { + term: { + type: 'string', + }, + fields: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + fields: { + type: 'array', + items: { + type: 'string', + }, + }, query: { - $ref: '#/components/schemas/EntityPredicate', - }, - }, - }, - examples: { - 'Get all service components': { - value: { - query: { - $all: [ - { - kind: 'component', - }, - { - 'spec.type': 'service', - }, - ], - }, - }, - }, - 'Get components owned by specific teams': { - value: { - query: { - $all: [ - { - kind: 'component', - }, - { - 'spec.owner': { - $in: ['backend-team', 'platform-team'], - }, - }, - ], - }, - }, - }, - 'Get non-production services': { - value: { - query: { - $all: [ - { - kind: 'component', - }, - { - 'spec.type': 'service', - }, - { - $not: { - 'spec.lifecycle': 'production', - }, - }, - ], - }, + $ref: '#/components/schemas/JsonObject', }, }, }, diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index cf262c7778..894949a691 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -29,7 +29,6 @@ describe('AuthorizedEntitiesCatalog', () => { const fakeCatalog = { entities: jest.fn(), entitiesBatch: jest.fn(), - queryEntitiesByPredicate: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), facets: jest.fn(), diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index 831a3bb76b..d3d0dba5b3 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -32,7 +32,6 @@ import { EntityAncestryResponse, EntityFacetsRequest, EntityFacetsResponse, - EntityPredicateRequest, QueryEntitiesRequest, QueryEntitiesResponse, } from '../catalog/types'; @@ -197,36 +196,6 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { return this.entitiesCatalog.queryEntities(request); } - async queryEntitiesByPredicate( - request?: EntityPredicateRequest, - ): Promise { - if (!request) { - return { - entities: { type: 'object', entities: [] }, - pageInfo: { hasNextPage: false }, - }; - } - - const authorizeDecision = ( - await this.permissionApi.authorizeConditional( - [{ permission: catalogEntityReadPermission }], - { credentials: request.credentials }, - ) - )[0]; - - if (authorizeDecision.result === AuthorizeResult.DENY) { - return { - entities: { type: 'object', entities: [] }, - pageInfo: { hasNextPage: false }, - }; - } - - // Note: For CONDITIONAL results, we pass through to the underlying catalog - // since EntityPredicate filters are separate from EntityFilter permission conditions. - // The permission filter would need to be applied separately if needed. - return this.entitiesCatalog.queryEntitiesByPredicate(request); - } - async removeEntityByUid( uid: string, options: { credentials: BackstageCredentials }, diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 151e345f5b..11bce7bd86 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -31,7 +31,6 @@ import { EntityFacetsResponse, EntityOrder, EntityPagination, - EntityPredicateRequest, QueryEntitiesRequest, QueryEntitiesResponse, } from '../catalog/types'; @@ -46,8 +45,6 @@ import { import { Stitcher } from '../stitching/types'; import { - decodeCursor, - encodeCursor, expandLegacyCompoundRelationsInEntity, isQueryEntitiesCursorRequest, isQueryEntitiesInitialRequest, @@ -55,7 +52,6 @@ import { import { EntityFilter } from '@backstage/plugin-catalog-node'; import { LoggerService } from '@backstage/backend-plugin-api'; import { applyEntityFilterToQuery } from './request/applyEntityFilterToQuery'; -import { applyPredicateEntityFilterToQuery } from './request/applyPredicateEntityFilterToQuery'; import { processRawEntitiesResult } from './response'; const DEFAULT_LIMIT = 200; @@ -217,156 +213,6 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { }; } - async queryEntitiesByPredicate( - request?: EntityPredicateRequest, - ): Promise { - const db = this.database; - const { limit, offset } = parsePagination(request?.pagination); - - const cursor = request?.pagination?.after - ? decodeCursor(request.pagination.after) - : { - query: request?.query, - orderFields: request?.order || [], - isPrevious: false, - orderFieldValues: undefined, - firstSortFieldValues: undefined, - }; - - // Use query from cursor if not provided in request (pagination case) - const effectiveQuery = request?.query ?? cursor.query; - const sortField = cursor.orderFields?.[0]; - - let entitiesQuery = db('final_entities'); - - // Join with search table if we have a sort field - if (sortField) { - entitiesQuery = entitiesQuery - .distinct() - .leftOuterJoin({ order_0: 'search' }, function search(inner) { - inner - .on('order_0.entity_id', 'final_entities.entity_id') - .andOn('order_0.key', db.raw('?', [sortField.field])); - }) - .select({ - entity_id: 'final_entities.entity_id', - final_entity: 'final_entities.final_entity', - value: 'order_0.value', - }); - } else { - entitiesQuery = entitiesQuery.select({ - entity_id: 'final_entities.entity_id', - final_entity: 'final_entities.final_entity', - }); - } - - entitiesQuery = entitiesQuery.whereNotNull('final_entities.final_entity'); - - // Apply predicate filter from cursor - if (effectiveQuery) { - entitiesQuery = applyPredicateEntityFilterToQuery({ - filter: effectiveQuery, - targetQuery: entitiesQuery, - onEntityIdField: 'final_entities.entity_id', - knex: db, - }); - } - - // Apply cursor-based pagination (keyset pagination) - if (cursor.orderFieldValues) { - if (cursor.orderFieldValues.length === 2) { - const [sortValue, entityId] = cursor.orderFieldValues; - const isOrderingDescending = sortField?.order === 'desc'; - entitiesQuery = entitiesQuery.andWhere(function nested() { - this.where( - 'order_0.value', - isOrderingDescending ? '<' : '>', - sortValue, - ) - .orWhere('order_0.value', '=', sortValue) - .andWhere('final_entities.entity_id', '>', entityId); - }); - } else if (cursor.orderFieldValues.length === 1) { - const [entityId] = cursor.orderFieldValues; - entitiesQuery = entitiesQuery.andWhere( - 'final_entities.entity_id', - '>', - entityId, - ); - } - } - - if (sortField) { - if (db.client.config.client === 'pg') { - entitiesQuery = entitiesQuery.orderBy([ - { column: 'order_0.value', order: sortField.order, nulls: 'last' }, - { column: 'final_entities.entity_id', order: 'asc' }, - ]); - } else { - entitiesQuery = entitiesQuery.orderBy([ - { column: 'order_0.value', order: undefined, nulls: 'last' }, - { column: 'order_0.value', order: sortField.order }, - { column: 'final_entities.entity_id', order: 'asc' }, - ]); - } - } else { - entitiesQuery = entitiesQuery.orderBy('final_entities.entity_id', 'asc'); - } - - // Apply a manually set initial offset (only when not using cursor pagination) - if (!request?.pagination?.after && offset !== undefined) { - entitiesQuery = entitiesQuery.offset(offset); - } - const effectiveLimit = limit ?? DEFAULT_LIMIT; - entitiesQuery = entitiesQuery.limit(effectiveLimit + 1); - - let rows = await entitiesQuery; - let pageInfo: DbPageInfo; - - if (rows.length <= effectiveLimit) { - pageInfo = { hasNextPage: false }; - } else { - // Remove the extra row - rows = rows.slice(0, -1); - - const lastRow = rows[rows.length - 1]; - const firstRow = rows[0]; - - // Create proper cursor with query field - const nextCursor: Cursor = { - query: effectiveQuery, - orderFields: cursor.orderFields || [], - orderFieldValues: sortField - ? [(lastRow as any).value, lastRow.entity_id] - : [lastRow.entity_id], - isPrevious: false, - firstSortFieldValues: - cursor.firstSortFieldValues || - (sortField - ? [(firstRow as any).value, firstRow.entity_id] - : [firstRow.entity_id]), - }; - - pageInfo = { - hasNextPage: true, - endCursor: encodeCursor(nextCursor), - }; - } - - return { - entities: processRawEntitiesResult( - rows.map(r => r.final_entity!), - this.enableRelationsCompatibility - ? e => { - expandLegacyCompoundRelationsInEntity(e); - return e; - } - : undefined, - ), - pageInfo, - }; - } - async entitiesBatch( request: EntitiesBatchRequest, ): Promise { @@ -457,10 +303,11 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { }); } - // Add regular filters, if given - if (cursor.filter) { + // Add regular filters and/or predicate query, if given + if (cursor.filter || cursor.query) { applyEntityFilterToQuery({ filter: cursor.filter, + query: cursor.query, targetQuery: inner, onEntityIdField: 'final_entities.entity_id', knex: this.database, diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 2a792f1de1..4571d3bc68 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -72,7 +72,7 @@ describe('createRouter readonly disabled', () => { beforeEach(async () => { entitiesCatalog = { entities: jest.fn(), - queryEntitiesByPredicate: jest.fn(), + entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), @@ -480,6 +480,62 @@ describe('createRouter readonly disabled', () => { }); }); + describe('POST /entities/by-query', () => { + it('queries entities with a predicate filter', async () => { + const items: Entity[] = [ + { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, + ]; + entitiesCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities: items }, + pageInfo: {}, + totalItems: 1, + }); + + const response = await request(app) + .post('/entities/by-query') + .send({ query: { kind: 'b' }, limit: 10 }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + items, + totalItems: 1, + pageInfo: {}, + }); + expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ + query: { kind: 'b' }, + limit: 10, + credentials: mockCredentials.user(), + }), + ); + }); + + it('paginates with a cursor in the body', async () => { + const items: Entity[] = [ + { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, + ]; + const cursor = mockCursor({ totalItems: 100, isPrevious: false }); + + entitiesCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities: items }, + pageInfo: { nextCursor: mockCursor() }, + totalItems: 100, + }); + + const response = await request(app) + .post('/entities/by-query') + .send({ cursor: encodeCursor(cursor) }); + + expect(response.status).toEqual(200); + expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ + cursor, + credentials: mockCredentials.user(), + }), + ); + }); + }); + describe('GET /entities/by-uid/:uid', () => { it('can fetch entity by uid', async () => { const entity: Entity = { @@ -1121,7 +1177,7 @@ describe('createRouter readonly and raw json enabled', () => { beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), - queryEntitiesByPredicate: jest.fn(), + entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), @@ -1338,7 +1394,7 @@ describe('NextRouter permissioning', () => { beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), - queryEntitiesByPredicate: jest.fn(), + entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index c3608c19a6..48bf7961b8 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -30,7 +30,6 @@ import { } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { InputError, serializeError } from '@backstage/errors'; -import { parseFilterPredicate } from '@backstage/filter-predicates'; import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; import express from 'express'; import yn from 'yn'; @@ -66,7 +65,7 @@ import { encodeLocationQueryCursor, parseLocationQuery, } from './request/parseLocationQuery'; -import { parseEntityOrderFieldParams } from './request/parseEntityOrderFieldParams'; +import { parseEntityQuery } from './request/parseEntityQuery'; /** * Options used by {@link createRouter}. @@ -265,31 +264,34 @@ export async function createRouter( eventId: 'entity-fetch', request: req, meta: { - queryType: 'by-query-predicate', + queryType: 'by-query', }, }); try { - // Validate the query using the Zod schema from @backstage/filter-predicates - const query = req.body.query - ? parseFilterPredicate(req.body.query) - : undefined; - const order = parseEntityOrderFieldParams(req.query); - const pagination = parseEntityPaginationParams(req.query); const credentials = await httpAuth.credentials(req); + const { fields: rawFields, ...parsed } = parseEntityQuery( + req.body ?? {}, + ); + const fields = rawFields?.length + ? parseEntityTransformParams({ fields: rawFields }) + : undefined; - const { entities, pageInfo } = - await entitiesCatalog.queryEntitiesByPredicate({ - query, - order, - pagination, + const { items, pageInfo, totalItems } = + await entitiesCatalog.queryEntities({ credentials, + fields, + ...parsed, }); const meta = { + totalItems, pageInfo: { - ...(pageInfo.hasNextPage && { - nextCursor: pageInfo.endCursor, + ...(pageInfo.nextCursor && { + nextCursor: encodeCursor(pageInfo.nextCursor), + }), + ...(pageInfo.prevCursor && { + prevCursor: encodeCursor(pageInfo.prevCursor), }), }, }; @@ -298,10 +300,10 @@ export async function createRouter( await writeEntitiesResponse({ res, - items: entities, + items, alwaysUseObjectMode: enableRelationsCompatibility, - responseWrapper: items => ({ - items, + responseWrapper: entities => ({ + items: entities, ...meta, }), }); diff --git a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts index ea64f5b400..8d3db17934 100644 --- a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts @@ -108,7 +108,7 @@ describe.each(databases.eachSupportedId())( } // #endregion - describe.each(strategies)('with strategy %p', strategy => { + describe.each(strategies)('with strategy %p', _strategy => { async function query(filter: EntityFilter): Promise { const q = knex('final_entities').whereNotNull( @@ -119,7 +119,6 @@ describe.each(databases.eachSupportedId())( targetQuery: q, onEntityIdField: 'final_entities.entity_id', knex, - strategy, }); return await q.then(rows => rows diff --git a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts index b2a7fb6708..7cbe4f5f58 100644 --- a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts @@ -18,8 +18,10 @@ import { EntitiesSearchFilter, EntityFilter, } from '@backstage/plugin-catalog-node'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { Knex } from 'knex'; import { DbSearchRow } from '../../database/tables'; +import { applyPredicateEntityFilterToQuery } from './applyPredicateEntityFilterToQuery'; function isEntitiesSearchFilter( filter: EntitiesSearchFilter | EntityFilter, @@ -118,13 +120,28 @@ function applyInStrategy( // The actual exported function export function applyEntityFilterToQuery(options: { - filter: EntityFilter; + filter?: EntityFilter; + query?: FilterPredicate; targetQuery: Knex.QueryBuilder; onEntityIdField: string; knex: Knex; - strategy?: 'in' | 'join'; }): Knex.QueryBuilder { - const { filter, targetQuery, onEntityIdField, knex } = options; + const { filter, query, targetQuery, onEntityIdField, knex } = options; - return applyInStrategy(filter, targetQuery, onEntityIdField, knex, false); + let result = targetQuery; + + if (filter) { + result = applyInStrategy(filter, result, onEntityIdField, knex, false); + } + + if (query) { + result = applyPredicateEntityFilterToQuery({ + filter: query, + targetQuery: result, + onEntityIdField, + knex, + }); + } + + return result; } diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index eff7c12f7e..db39a2195a 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -165,15 +165,14 @@ function applyPredicateInStrategy( ); } - // Handle primitive value at top level (e.g., "component" shorthand) + // Reject primitives at the top level. Matching by value without specifying + // a field key is ambiguous and should not be allowed. if (isPrimitive(filter)) { - const matchQuery = knex('search') - .select('search.entity_id') - .where({ value: String(filter).toLowerCase() }); - return targetQuery.andWhere( - onEntityIdField, - negate ? 'not in' : 'in', - matchQuery, + throw new InputError( + `Invalid filter predicate: top-level primitive values are not supported. ` + + `Wrap the value in a field expression, e.g. { "kind": ${JSON.stringify( + filter, + )} }`, ); } @@ -233,7 +232,6 @@ export function applyPredicateEntityFilterToQuery(options: { targetQuery: Knex.QueryBuilder; onEntityIdField: string; knex: Knex; - strategy?: 'in' | 'join'; }): Knex.QueryBuilder { const { filter, targetQuery, onEntityIdField, knex } = options; diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts new file mode 100644 index 0000000000..c22d849391 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts @@ -0,0 +1,115 @@ +/* + * 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'; +import { QueryEntitiesByPredicateRequest } from '../../schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model'; +import { EntityOrder } from '../../catalog/types'; +import { Cursor } from '../../catalog/types'; +import { decodeCursor } from '../util'; + +const filterPredicateSchema = createZodV3FilterPredicateSchema(z); + +function isSupportedFilterPredicateRoot( + value: FilterPredicate | undefined, +): boolean { + if (value === undefined) { + return true; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + return true; +} + +function parseOrderFields( + orderField: string[] | undefined, +): EntityOrder[] | undefined { + if (!orderField?.length) { + return undefined; + } + return orderField.map(entry => { + const [field, order] = entry.split(','); + if (order !== undefined && order !== 'asc' && order !== 'desc') { + throw new InputError('Invalid order field order, must be asc or desc'); + } + return { field, order: order as 'asc' | 'desc' }; + }); +} + +export type ParsedEntityQuery = + | { + cursor: Cursor; + fields?: string[]; + limit?: number; + } + | { + query?: FilterPredicate; + orderFields?: EntityOrder[]; + fullTextFilter?: { term: string; fields?: string[] }; + fields?: string[]; + limit?: number; + offset?: number; + }; + +export function parseEntityQuery( + request: Readonly, +): ParsedEntityQuery { + if (request.cursor !== undefined) { + if (!request.cursor) { + throw new InputError('Cursor cannot be empty'); + } + + const cursor = decodeCursor(request.cursor); + return { + cursor, + fields: request.fields, + limit: request.limit, + }; + } + + 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)}`); + } + if (!isSupportedFilterPredicateRoot(result.data)) { + throw new InputError('Query must be an object'); + } + query = result.data; + } + + const orderFields = parseOrderFields(request.orderField); + + return { + query, + orderFields, + fullTextFilter: request.fullTextFilter + ? { + term: request.fullTextFilter.term ?? '', + fields: request.fullTextFilter.fields, + } + : undefined, + fields: request.fields, + limit: request.limit, + }; +} From b2b4f06f617748230661db65f0f5d4cb21800359 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Feb 2026 10:35:23 +0100 Subject: [PATCH 009/118] chore: small fix Signed-off-by: benjdlambert --- plugins/catalog-backend/src/service/request/parseEntityQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts index c22d849391..5ad505b8a1 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityQuery.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts @@ -20,7 +20,7 @@ import { FilterPredicate, } from '@backstage/filter-predicates'; import { z } from 'zod/v3'; -import { fromZodError } from 'zod-validation-error'; +import { fromZodError } from 'zod-validation-error/v3'; import { QueryEntitiesByPredicateRequest } from '../../schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model'; import { EntityOrder } from '../../catalog/types'; import { Cursor } from '../../catalog/types'; From 58edede71653ad9d19c284653444d80ac9cac6d8 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Feb 2026 10:45:55 +0100 Subject: [PATCH 010/118] chore: refactor a little bit Signed-off-by: benjdlambert --- .../openapi/generated/apis/Api.client.ts | 2 +- .../catalog-backend/src/schema/openapi.yaml | 21 +------------------ .../src/schema/openapi/generated/router.ts | 3 +-- .../request/applyEntityFilterToQuery.test.ts | 3 ++- .../request/applyEntityFilterToQuery.ts | 1 + 5 files changed, 6 insertions(+), 24 deletions(-) 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 5a5eb2ec5d..086f3695d4 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 @@ -457,7 +457,7 @@ export class DefaultApiClient { } /** - * Query entities using predicate-based filters. This endpoint provides an alternative filtering method with a more expressive filter syntax supporting logical operators ($all, $any, $not) and value operators ($exists, $in). Example query: ```json { \"query\": { \"$all\": [ {\"kind\": \"component\"}, {\"$any\": [ {\"spec.type\": \"service\"}, {\"spec.type\": \"website\"} ]}, {\"$not\": {\"spec.lifecycle\": \"experimental\"}} ] } } ``` + * Query entities using predicate-based filters. * @param queryEntitiesByPredicateRequest - */ public async queryEntitiesByPredicate( diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index f6b80ca062..f44ac026e4 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -1099,26 +1099,7 @@ paths: operationId: QueryEntitiesByPredicate tags: - Entity - description: | - Query entities using predicate-based filters. This endpoint provides an - alternative filtering method with a more expressive filter syntax supporting - logical operators ($all, $any, $not) and value operators ($exists, $in). - - Example query: - ```json - { - "query": { - "$all": [ - {"kind": "component"}, - {"$any": [ - {"spec.type": "service"}, - {"spec.type": "website"} - ]}, - {"$not": {"spec.lifecycle": "experimental"}} - ] - } - } - ``` + description: Query entities using predicate-based filters. responses: '200': description: Ok diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index afac4ce5f8..86ddeddf61 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -1231,8 +1231,7 @@ export const spec = { post: { operationId: 'QueryEntitiesByPredicate', tags: ['Entity'], - description: - 'Query entities using predicate-based filters. This endpoint provides an\nalternative filtering method with a more expressive filter syntax supporting\nlogical operators ($all, $any, $not) and value operators ($exists, $in).\n\nExample query:\n```json\n{\n "query": {\n "$all": [\n {"kind": "component"},\n {"$any": [\n {"spec.type": "service"},\n {"spec.type": "website"}\n ]},\n {"$not": {"spec.lifecycle": "experimental"}}\n ]\n }\n}\n```\n', + description: 'Query entities using predicate-based filters.', responses: { '200': { description: 'Ok', diff --git a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts index 8d3db17934..ea64f5b400 100644 --- a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts @@ -108,7 +108,7 @@ describe.each(databases.eachSupportedId())( } // #endregion - describe.each(strategies)('with strategy %p', _strategy => { + describe.each(strategies)('with strategy %p', strategy => { async function query(filter: EntityFilter): Promise { const q = knex('final_entities').whereNotNull( @@ -119,6 +119,7 @@ describe.each(databases.eachSupportedId())( targetQuery: q, onEntityIdField: 'final_entities.entity_id', knex, + strategy, }); return await q.then(rows => rows diff --git a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts index 7cbe4f5f58..49356cd0ff 100644 --- a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts @@ -125,6 +125,7 @@ export function applyEntityFilterToQuery(options: { targetQuery: Knex.QueryBuilder; onEntityIdField: string; knex: Knex; + strategy?: 'in' | 'join'; }): Knex.QueryBuilder { const { filter, query, targetQuery, onEntityIdField, knex } = options; From 723b94aa16b28dcaecfa8a5b9fb74bf4d341ab05 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Feb 2026 10:57:37 +0100 Subject: [PATCH 011/118] chore: added some tests Signed-off-by: benjdlambert --- .../applyPredicateEntityFilterToQuery.test.ts | 220 ++++++++++++++++++ .../service/request/parseEntityQuery.test.ts | 199 ++++++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts create mode 100644 plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts new file mode 100644 index 0000000000..63b0bb9b24 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -0,0 +1,220 @@ +/* + * 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 { TestDatabases } from '@backstage/backend-test-utils'; +import { applyEntityFilterToQuery } from './applyEntityFilterToQuery'; +import { + DbFinalEntitiesRow, + DbRefreshStateRow, + DbSearchRow, +} from '../../database/tables'; +import { Knex } from 'knex'; +import { applyDatabaseMigrations } from '../../database/migrations'; +import { FilterPredicate } from '@backstage/filter-predicates'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { v4 as uuid } from 'uuid'; +import { buildEntitySearch } from '../../database/operations/stitcher/buildEntitySearch'; + +jest.setTimeout(60_000); + +const databases = TestDatabases.create(); + +describe.each(databases.eachSupportedId())( + 'applyEntityFilterToQuery with predicate queries, %p', + databaseId => { + let knex: Knex; + + beforeAll(async () => { + knex = await databases.init(databaseId); + await applyDatabaseMigrations(knex); + await addEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'service-a', namespace: 'default' }, + spec: { type: 'service', lifecycle: 'production', owner: 'team-a' }, + }); + await addEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'service-b', namespace: 'default' }, + spec: { type: 'service', lifecycle: 'experimental', owner: 'team-b' }, + }); + await addEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'website-c', namespace: 'default' }, + spec: { type: 'website', lifecycle: 'production', owner: 'team-a' }, + }); + await addEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { name: 'api-d', namespace: 'default' }, + spec: { type: 'openapi', lifecycle: 'production' }, + }); + await addEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'bare-e', namespace: 'default' }, + spec: {}, + }); + }); + + afterAll(async () => { + knex.destroy(); + }); + + async function addEntity(entity: Entity) { + const id = uuid(); + const entityRef = stringifyEntityRef(entity); + const entityJson = JSON.stringify(entity); + + await knex('refresh_state').insert({ + entity_id: id, + entity_ref: entityRef, + unprocessed_entity: entityJson, + errors: '[]', + next_update_at: '2031-01-01 23:00:00', + last_discovery_at: '2021-04-01 13:37:00', + }); + + await knex('final_entities').insert({ + entity_id: id, + entity_ref: entityRef, + final_entity: entityJson, + hash: 'h', + stitch_ticket: '', + }); + + const search = await buildEntitySearch(id, entity); + await knex('search').insert(search); + } + + async function query(predicate: FilterPredicate): Promise { + const q = + knex('final_entities').whereNotNull('final_entity'); + applyEntityFilterToQuery({ + query: predicate, + targetQuery: q, + onEntityIdField: 'final_entities.entity_id', + knex, + }); + return await q.then(rows => + rows.map(row => JSON.parse(row.final_entity!).metadata.name).toSorted(), + ); + } + + it('filters by direct field value', async () => { + await expect(query({ kind: 'component' })).resolves.toEqual([ + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + + it('filters by exact spec field value', async () => { + await expect(query({ 'spec.type': 'service' })).resolves.toEqual([ + 'service-a', + 'service-b', + ]); + }); + + it('filters with $all', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.type': 'service' }], + }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + + it('filters with $any', async () => { + await expect( + query({ + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('filters with $not', async () => { + await expect( + query({ + $all: [ + { kind: 'component' }, + { $not: { 'spec.lifecycle': 'experimental' } }, + ], + }), + ).resolves.toEqual(['bare-e', 'service-a', 'website-c']); + }); + + it('filters with $in', async () => { + await expect( + query({ 'spec.type': { $in: ['service', 'openapi'] } }), + ).resolves.toEqual(['api-d', 'service-a', 'service-b']); + }); + + it('filters with $exists true', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.owner': { $exists: true } }], + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('filters with $exists false', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.owner': { $exists: false } }], + }), + ).resolves.toEqual(['bare-e']); + }); + + it('handles nested logical operators', async () => { + await expect( + query({ + $all: [ + { kind: 'component' }, + { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + { $not: { 'spec.lifecycle': 'experimental' } }, + ], + }), + ).resolves.toEqual(['service-a', 'website-c']); + }); + + it('throws on top-level primitive', async () => { + await expect(query('bad-value' as any)).rejects.toThrow( + /top-level primitive values are not supported/, + ); + }); + + it('combines filter and query independently', async () => { + const q = + knex('final_entities').whereNotNull('final_entity'); + applyEntityFilterToQuery({ + filter: { key: 'kind', values: ['component'] }, + query: { 'spec.type': 'service' }, + targetQuery: q, + onEntityIdField: 'final_entities.entity_id', + knex, + }); + const result = await q.then(rows => + rows.map(row => JSON.parse(row.final_entity!).metadata.name).toSorted(), + ); + expect(result).toEqual(['service-a', 'service-b']); + }); + }, +); diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts new file mode 100644 index 0000000000..8b6d1c21b7 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts @@ -0,0 +1,199 @@ +/* + * 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 { parseEntityQuery } from './parseEntityQuery'; +import { encodeCursor } from '../util'; +import { Cursor } from '../../catalog/types'; + +describe('parseEntityQuery', () => { + describe('initial request', () => { + it('returns empty result for empty request', () => { + const result = parseEntityQuery({}); + expect(result).toEqual({ + query: undefined, + orderFields: undefined, + fullTextFilter: undefined, + fields: undefined, + limit: undefined, + }); + }); + + it('parses a simple query predicate', () => { + const query = { kind: 'component' }; + const result = parseEntityQuery({ query }); + expect(result).toEqual( + expect.objectContaining({ query: { kind: 'component' } }), + ); + }); + + it('parses a complex query with $all, $any, $not', () => { + const query = { + $all: [ + { kind: 'component' }, + { $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }] }, + { $not: { 'spec.lifecycle': 'experimental' } }, + ], + }; + const result = parseEntityQuery({ query }); + expect(result).toEqual(expect.objectContaining({ query })); + }); + + it('parses query with $exists operator', () => { + const query = { 'metadata.labels.team': { $exists: true } }; + const result = parseEntityQuery({ query }); + expect(result).toEqual(expect.objectContaining({ query })); + }); + + it('parses query with $in operator', () => { + const query = { kind: { $in: ['component', 'api'] } }; + const result = parseEntityQuery({ query }); + expect(result).toEqual(expect.objectContaining({ query })); + }); + + it('passes through limit', () => { + const result = parseEntityQuery({ limit: 50 }); + expect(result).toEqual(expect.objectContaining({ limit: 50 })); + }); + + it('passes through fields', () => { + const result = parseEntityQuery({ + fields: ['metadata.name', 'kind'], + }); + expect(result).toEqual( + expect.objectContaining({ fields: ['metadata.name', 'kind'] }), + ); + }); + + it('parses orderField into orderFields', () => { + const result = parseEntityQuery({ + orderField: ['metadata.name,asc', 'metadata.namespace,desc'], + }); + expect(result).toEqual( + expect.objectContaining({ + orderFields: [ + { field: 'metadata.name', order: 'asc' }, + { field: 'metadata.namespace', order: 'desc' }, + ], + }), + ); + }); + + it('parses fullTextFilter', () => { + const result = parseEntityQuery({ + fullTextFilter: { term: 'search term', fields: ['metadata.name'] }, + }); + expect(result).toEqual( + expect.objectContaining({ + fullTextFilter: { + term: 'search term', + fields: ['metadata.name'], + }, + }), + ); + }); + + it('defaults fullTextFilter term to empty string when missing', () => { + const result = parseEntityQuery({ + fullTextFilter: {} as any, + }); + expect(result).toEqual( + expect.objectContaining({ + fullTextFilter: { term: '', fields: undefined }, + }), + ); + }); + + it('throws on invalid query predicate', () => { + expect(() => + parseEntityQuery({ query: { $invalid: true } as any }), + ).toThrow(/Invalid query/); + }); + + it('throws when query root is not an object', () => { + expect(() => parseEntityQuery({ query: 'bad' as any })).toThrow( + /Query must be an object/, + ); + }); + + it('throws on invalid orderField order value', () => { + expect(() => + parseEntityQuery({ orderField: ['metadata.name,sideways'] }), + ).toThrow(/Invalid order field order/); + }); + }); + + describe('cursor request', () => { + function makeCursor(partial: Partial): string { + const full: Cursor = { + orderFields: [], + orderFieldValues: [], + isPrevious: false, + ...partial, + }; + return encodeCursor(full); + } + + it('decodes a valid cursor', () => { + const cursor = makeCursor({ + orderFields: [{ field: 'metadata.name', order: 'asc' }], + orderFieldValues: ['test'], + isPrevious: false, + }); + const result = parseEntityQuery({ cursor }); + expect(result).toEqual( + expect.objectContaining({ + cursor: expect.objectContaining({ + orderFields: [{ field: 'metadata.name', order: 'asc' }], + orderFieldValues: ['test'], + isPrevious: false, + }), + }), + ); + }); + + it('passes through limit and fields with cursor', () => { + const cursor = makeCursor({}); + const result = parseEntityQuery({ + cursor, + limit: 25, + fields: ['metadata.name'], + }); + expect(result).toEqual( + expect.objectContaining({ + limit: 25, + fields: ['metadata.name'], + }), + ); + }); + + it('throws on empty cursor string', () => { + expect(() => parseEntityQuery({ cursor: '' })).toThrow( + /Cursor cannot be empty/, + ); + }); + + it('throws on invalid base64 cursor', () => { + expect(() => parseEntityQuery({ cursor: '!!not-valid!!' })).toThrow( + /Malformed cursor/, + ); + }); + + it('throws on invalid JSON in cursor', () => { + const cursor = Buffer.from('not json', 'utf8').toString('base64'); + expect(() => parseEntityQuery({ cursor })).toThrow(/Malformed cursor/); + }); + }); +}); From e172faf7be85179aace9922216f279c8e45cc79c Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Feb 2026 11:02:14 +0100 Subject: [PATCH 012/118] feat: add support for the querying in the catalog client Signed-off-by: benjdlambert --- .../testUtils/InMemoryCatalogClient.test.ts | 77 +++++++++++++++++++ .../src/testUtils/InMemoryCatalogClient.ts | 13 ++++ 2 files changed, 90 insertions(+) diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts index d867ab1d28..aa3b624050 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts @@ -683,6 +683,83 @@ describe('InMemoryCatalogClient', () => { ]); }); + it('filters by predicate query', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { kind: 'CustomKind' }, + }); + expect(result.items).toEqual([entity1, entity3]); + expect(result.totalItems).toBe(2); + }); + + it('filters by predicate query with $all', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { + $all: [{ kind: 'CustomKind' }, { 'spec.type': 'service' }], + }, + }); + expect(result.items).toEqual([entity1, entity3]); + }); + + it('filters by predicate query with $any', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + }); + expect(result.items).toEqual([entity1, entity3, entity4]); + }); + + it('filters by predicate query with $not', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { + $all: [ + { kind: 'CustomKind' }, + { $not: { 'spec.lifecycle': 'production' } }, + ], + }, + }); + expect(result.items).toEqual([]); + }); + + it('filters by predicate query with $in', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { 'spec.type': { $in: ['service', 'library'] } }, + }); + expect(result.items).toEqual([entity1, entity2, entity3]); + }); + + it('filters by predicate query with $exists', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { 'spec.lifecycle': { $exists: false } }, + }); + expect(result.items).toEqual([entity4]); + }); + + it('preserves query predicate through cursor pagination', async () => { + const client = new InMemoryCatalogClient({ entities }); + const page1 = await client.queryEntities({ + query: { kind: 'CustomKind' }, + orderFields: { field: 'metadata.name', order: 'asc' }, + limit: 1, + }); + expect(page1.items.map(e => e.metadata.name)).toEqual(['e1']); + expect(page1.totalItems).toBe(2); + expect(page1.pageInfo.nextCursor).toBeDefined(); + + const page2 = await client.queryEntities({ + cursor: page1.pageInfo.nextCursor!, + limit: 1, + }); + expect(page2.items.map(e => e.metadata.name)).toEqual(['e3']); + expect(page2.pageInfo.nextCursor).toBeUndefined(); + }); + it('throws InputError for invalid cursor', async () => { const client = new InMemoryCatalogClient({ entities }); await expect( diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts index e64706ff8d..5ffaafa16f 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts @@ -51,6 +51,7 @@ import { NotFoundError, NotImplementedError, } from '@backstage/errors'; +import { filterPredicateToFilterFunction } from '@backstage/filter-predicates'; import lodash from 'lodash'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { traverse } from '../../../../plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch'; @@ -373,6 +374,7 @@ export class InMemoryCatalogClient implements CatalogApi { ): Promise { // Decode query parameters from cursor or from the request directly let filter: EntityFilterQuery | undefined; + let query: Record | undefined; let orderFields: EntityOrderQuery | undefined; let fullTextFilter: { term: string; fields?: string[] } | undefined; let offset: number; @@ -386,12 +388,17 @@ export class InMemoryCatalogClient implements CatalogApi { throw new InputError('Invalid cursor'); } filter = deserializeFilter(c.filter as any[]); + query = c.query as Record | undefined; orderFields = c.orderFields as EntityOrderQuery | undefined; fullTextFilter = c.fullTextFilter as typeof fullTextFilter; offset = c.offset as number; limit = request.limit; } else { filter = request?.filter; + query = + request?.query && typeof request.query === 'object' + ? (request.query as Record) + : undefined; orderFields = request?.orderFields; fullTextFilter = request?.fullTextFilter; offset = request?.offset ?? 0; @@ -401,6 +408,11 @@ export class InMemoryCatalogClient implements CatalogApi { // Apply filter let items = this.#entities.filter(createFilter(filter)); + // Apply predicate-based query filter + if (query) { + items = items.filter(filterPredicateToFilterFunction(query)); + } + // Apply full-text filter, defaulting to the sort field or metadata.uid if (fullTextFilter) { const orderFieldsList = orderFields ? [orderFields].flat() : []; @@ -432,6 +444,7 @@ export class InMemoryCatalogClient implements CatalogApi { const cursorBase = { filter: serializeFilter(filter), + query, orderFields, fullTextFilter, totalItems, From c01f24006972acf5cdc0908692b6f870d460518e Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Feb 2026 12:00:58 +0100 Subject: [PATCH 013/118] chore: fix typescript Signed-off-by: benjdlambert --- .../src/testUtils/InMemoryCatalogClient.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts index 5ffaafa16f..0a4dc57386 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts @@ -51,7 +51,10 @@ import { NotFoundError, NotImplementedError, } from '@backstage/errors'; -import { filterPredicateToFilterFunction } from '@backstage/filter-predicates'; +import { + FilterPredicate, + filterPredicateToFilterFunction, +} from '@backstage/filter-predicates'; import lodash from 'lodash'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { traverse } from '../../../../plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch'; @@ -374,7 +377,7 @@ export class InMemoryCatalogClient implements CatalogApi { ): Promise { // Decode query parameters from cursor or from the request directly let filter: EntityFilterQuery | undefined; - let query: Record | undefined; + let query: FilterPredicate | undefined; let orderFields: EntityOrderQuery | undefined; let fullTextFilter: { term: string; fields?: string[] } | undefined; let offset: number; @@ -388,17 +391,14 @@ export class InMemoryCatalogClient implements CatalogApi { throw new InputError('Invalid cursor'); } filter = deserializeFilter(c.filter as any[]); - query = c.query as Record | undefined; + query = c.query as FilterPredicate | undefined; orderFields = c.orderFields as EntityOrderQuery | undefined; fullTextFilter = c.fullTextFilter as typeof fullTextFilter; offset = c.offset as number; limit = request.limit; } else { filter = request?.filter; - query = - request?.query && typeof request.query === 'object' - ? (request.query as Record) - : undefined; + query = request?.query; orderFields = request?.orderFields; fullTextFilter = request?.fullTextFilter; offset = request?.offset ?? 0; From f0876da49f6a98baa378b28a9e67218f15528d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 10:35:15 +0100 Subject: [PATCH 014/118] use a structured orderBy for ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../catalog-client/src/CatalogClient.test.ts | 12 ++++--- packages/catalog-client/src/CatalogClient.ts | 4 +-- .../QueryEntitiesByPredicateRequest.model.ts | 3 +- ...iesByPredicateRequestOrderByInner.model.ts | 34 +++++++++++++++++++ .../schema/openapi/generated/models/index.ts | 1 + .../catalog-backend/src/schema/openapi.yaml | 15 ++++++-- .../QueryEntitiesByPredicateRequest.model.ts | 3 +- ...iesByPredicateRequestOrderByInner.model.ts | 34 +++++++++++++++++++ .../schema/openapi/generated/models/index.ts | 1 + .../src/schema/openapi/generated/router.ts | 14 ++++++-- .../service/request/parseEntityQuery.test.ts | 14 +++++--- .../src/service/request/parseEntityQuery.ts | 11 +++--- 12 files changed, 122 insertions(+), 24 deletions(-) create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 4ee93c0cba..aee10cd3f2 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -751,9 +751,11 @@ describe('CatalogClient', () => { expect(mockedEndpoint).toHaveBeenCalledTimes(1); }); - it('should send orderFields with correct format (field,order)', async () => { + it('should send orderFields with correct format', async () => { const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { - expect(req.body.orderField).toEqual(['metadata.name,asc']); + expect(req.body.orderBy).toEqual([ + { field: 'metadata.name', order: 'asc' }, + ]); return res(ctx.json(defaultResponse)); }); @@ -769,9 +771,9 @@ describe('CatalogClient', () => { it('should send multiple orderFields with correct format', async () => { const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { - expect(req.body.orderField).toEqual([ - 'metadata.name,asc', - 'spec.type,desc', + expect(req.body.orderBy).toEqual([ + { field: 'metadata.name', order: 'asc' }, + { field: 'spec.type', order: 'desc' }, ]); return res(ctx.json(defaultResponse)); }); diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 51d57f715e..a6f47933f2 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -368,9 +368,7 @@ export class CatalogClient implements CatalogApi { body.limit = limit; } if (orderFields !== undefined) { - body.orderField = ( - Array.isArray(orderFields) ? orderFields : [orderFields] - ).map(({ field, order }) => `${field},${order}`); + body.orderBy = [orderFields].flat(); } if (fullTextFilter) { body.fullTextFilter = fullTextFilter; diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index bdcd854a5c..818a780b4a 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -19,6 +19,7 @@ // ****************************************************************** import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; +import { QueryEntitiesByPredicateRequestOrderByInner } from '../models/QueryEntitiesByPredicateRequestOrderByInner.model'; /** * @public @@ -26,7 +27,7 @@ import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEn export interface QueryEntitiesByPredicateRequest { cursor?: string; limit?: number; - orderField?: Array; + orderBy?: Array; fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter; fields?: Array; /** diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts new file mode 100644 index 0000000000..373ca9715f --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts @@ -0,0 +1,34 @@ +/* + * 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 QueryEntitiesByPredicateRequestOrderByInner { + field: string; + order: QueryEntitiesByPredicateRequestOrderByInnerOrderEnum; +} + +/** + * @public + */ +export type QueryEntitiesByPredicateRequestOrderByInnerOrderEnum = + | 'asc' + | 'desc'; 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 889b5968fa..9461ac8d28 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/index.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/index.ts @@ -47,6 +47,7 @@ export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; export * from '../models/QueryEntitiesByPredicateRequest.model'; export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; +export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.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 f44ac026e4..41738c1148 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -1125,10 +1125,21 @@ paths: type: string limit: type: number - orderField: + orderBy: type: array items: - type: string + type: object + required: + - field + - order + properties: + field: + type: string + order: + type: string + enum: + - asc + - desc fullTextFilter: type: object properties: diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index bdcd854a5c..818a780b4a 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -19,6 +19,7 @@ // ****************************************************************** import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; +import { QueryEntitiesByPredicateRequestOrderByInner } from '../models/QueryEntitiesByPredicateRequestOrderByInner.model'; /** * @public @@ -26,7 +27,7 @@ import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEn export interface QueryEntitiesByPredicateRequest { cursor?: string; limit?: number; - orderField?: Array; + orderBy?: Array; fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter; fields?: Array; /** diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts new file mode 100644 index 0000000000..373ca9715f --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts @@ -0,0 +1,34 @@ +/* + * 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 QueryEntitiesByPredicateRequestOrderByInner { + field: string; + order: QueryEntitiesByPredicateRequestOrderByInnerOrderEnum; +} + +/** + * @public + */ +export type QueryEntitiesByPredicateRequestOrderByInnerOrderEnum = + | 'asc' + | 'desc'; 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 889b5968fa..9461ac8d28 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts @@ -47,6 +47,7 @@ export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; export * from '../models/QueryEntitiesByPredicateRequest.model'; export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; +export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.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 86ddeddf61..ed9536d6b9 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -1269,10 +1269,20 @@ export const spec = { limit: { type: 'number', }, - orderField: { + orderBy: { type: 'array', items: { - type: 'string', + type: 'object', + required: ['field', 'order'], + properties: { + field: { + type: 'string', + }, + order: { + type: 'string', + enum: ['asc', 'desc'], + }, + }, }, }, fullTextFilter: { diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts index 8b6d1c21b7..b95c4c6c0e 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts @@ -77,9 +77,12 @@ describe('parseEntityQuery', () => { ); }); - it('parses orderField into orderFields', () => { + it('parses orderBy into orderFields', () => { const result = parseEntityQuery({ - orderField: ['metadata.name,asc', 'metadata.namespace,desc'], + orderBy: [ + { field: 'metadata.name', order: 'asc' }, + { field: 'metadata.namespace', order: 'desc' }, + ], }); expect(result).toEqual( expect.objectContaining({ @@ -128,9 +131,12 @@ describe('parseEntityQuery', () => { ); }); - it('throws on invalid orderField order value', () => { + it('throws on invalid orderBy order value', () => { expect(() => - parseEntityQuery({ orderField: ['metadata.name,sideways'] }), + parseEntityQuery({ + // @ts-expect-error - invalid order value + orderBy: [{ field: 'metadata.name', order: 'sideways' }], + }), ).toThrow(/Invalid order field order/); }); }); diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts index 5ad505b8a1..c33492e740 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityQuery.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts @@ -41,17 +41,16 @@ function isSupportedFilterPredicateRoot( } function parseOrderFields( - orderField: string[] | undefined, + orderField: Array<{ field: string; order: string }> | undefined, ): EntityOrder[] | undefined { if (!orderField?.length) { return undefined; } - return orderField.map(entry => { - const [field, order] = entry.split(','); - if (order !== undefined && order !== 'asc' && order !== 'desc') { + return orderField.map(({ field, order }) => { + if (order !== 'asc' && order !== 'desc') { throw new InputError('Invalid order field order, must be asc or desc'); } - return { field, order: order as 'asc' | 'desc' }; + return { field, order }; }); } @@ -98,7 +97,7 @@ export function parseEntityQuery( query = result.data; } - const orderFields = parseOrderFields(request.orderField); + const orderFields = parseOrderFields(request.orderBy); return { query, From f076495eea5bdf960a250251ccc7a4acd90db327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 10:48:44 +0100 Subject: [PATCH 015/118] add todo about cursor introspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/catalog-client/src/CatalogClient.ts | 34 +++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index a6f47933f2..fa8a88c1ca 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -53,6 +53,7 @@ import { } from './utils'; import { DefaultApiClient, + GetEntitiesByQuery, GetLocationsByQueryRequest, QueryEntitiesByPredicateRequest, TypedResponse, @@ -271,35 +272,33 @@ export class CatalogClient implements CatalogApi { request: QueryEntitiesRequest = {}, options?: CatalogRequestOptions, ): Promise { + const isInitialRequest = isQueryEntitiesInitialRequest(request); + // Validate that filter and query are mutually exclusive - if ( - isQueryEntitiesInitialRequest(request) && - request.filter && - request.query - ) { + if (isInitialRequest && request.filter && request.query) { throw new Error( 'Cannot specify both "filter" and "query" in the same request. Use "filter" for traditional key-value filtering or "query" for predicate-based filtering.', ); } // Route to POST endpoint if query predicate is provided (initial request) - if (isQueryEntitiesInitialRequest(request) && request.query) { + if (isInitialRequest && request.query) { return this.queryEntitiesByPredicate(request, options); } // Route to POST endpoint if cursor contains a query predicate (pagination) - if ( - !isQueryEntitiesInitialRequest(request) && - cursorContainsQuery(request.cursor) - ) { + // TODO(freben): It's costly and non-opaque to have to introspect the cursor + // like this. It should be refactored in the future to not need this. + // Suggestion: make the GET and POST endpoints understand the same cursor + // format, and pick which one to call ONLY based on whether the cursor size + // risks hitting url length limits + if (!isInitialRequest && cursorContainsQuery(request.cursor)) { return this.queryEntitiesByPredicate(request, options); } - const params: Partial< - Parameters[0]['query'] - > = {}; + const params: Partial = {}; - if (isQueryEntitiesInitialRequest(request)) { + if (isInitialRequest) { const { fields = [], filter, @@ -357,7 +356,7 @@ export class CatalogClient implements CatalogApi { request: QueryEntitiesRequest, options?: CatalogRequestOptions, ): Promise { - const body: Record = {}; + const body: QueryEntitiesByPredicateRequest = {}; if (isQueryEntitiesInitialRequest(request)) { const { query, limit, orderFields, fullTextFilter, fields } = request; @@ -387,10 +386,7 @@ export class CatalogClient implements CatalogApi { } const res = await this.requestRequired( - await this.apiClient.queryEntitiesByPredicate( - { body: body as unknown as QueryEntitiesByPredicateRequest }, - options, - ), + await this.apiClient.queryEntitiesByPredicate({ body }, options), ); return { From f3b3b1dafb8eddc77f0506b2bf949a9c9568bab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 10:55:24 +0100 Subject: [PATCH 016/118] implement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../applyPredicateEntityFilterToQuery.test.ts | 12 +++++++++++ .../applyPredicateEntityFilterToQuery.ts | 21 ++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index 63b0bb9b24..2f216fd219 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -181,6 +181,18 @@ describe.each(databases.eachSupportedId())( ).resolves.toEqual(['bare-e']); }); + it('filters with $hasPrefix', async () => { + await expect( + query({ 'metadata.name': { $hasPrefix: 'service' } }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + + it('filters with $hasPrefix case-insensitively', async () => { + await expect( + query({ 'metadata.name': { $hasPrefix: 'Service' } }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + it('handles nested logical operators', async () => { await expect( query({ diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index db39a2195a..d077bd1460 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -61,6 +61,12 @@ function isInValue( return typeof value === 'object' && value !== null && '$in' in value; } +function isHasPrefixValue( + value: FilterPredicateValue, +): value is { $hasPrefix: string } { + return typeof value === 'object' && value !== null && '$hasPrefix' in value; +} + function isFieldExpression(filter: FilterPredicate): boolean { if (typeof filter !== 'object' || filter === null) { return false; @@ -202,6 +208,19 @@ function applyPredicateInStrategy( .where({ key: normalizedKey }) .whereIn('value', values); this.andWhere(onEntityIdField, 'in', matchQuery); + } else if (isHasPrefixValue(value)) { + // Handle $hasPrefix + const prefix = value.$hasPrefix.toLowerCase(); + const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`); + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key: normalizedKey }) + .andWhereRaw('?? like ? escape ?', [ + 'value', + `${escaped}%`, + '\\', + ]); + this.andWhere(onEntityIdField, 'in', matchQuery); } else if (isPrimitive(value)) { // Handle direct value match const matchQuery = knex('search') @@ -214,7 +233,7 @@ function applyPredicateInStrategy( } else { // Reject unsupported/invalid predicate values throw new InputError( - `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, or $in operator, but got ${JSON.stringify( + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${JSON.stringify( value, )}`, ); From 02443480b3f22752d2e4d11f7dbe642514f1bdfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 10:56:31 +0100 Subject: [PATCH 017/118] locale lowercase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../request/applyPredicateEntityFilterToQuery.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index d077bd1460..91b29b6ce1 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -187,7 +187,7 @@ function applyPredicateInStrategy( return targetQuery[negate ? 'andWhereNot' : 'andWhere']( function fieldFilter() { for (const [key, value] of Object.entries(filter)) { - const normalizedKey = key.toLowerCase(); + const normalizedKey = key.toLocaleLowerCase('en-US'); if (isExistsValue(value)) { // Handle $exists @@ -202,7 +202,9 @@ function applyPredicateInStrategy( } } else if (isInValue(value)) { // Handle $in - const values = value.$in.map(v => String(v).toLowerCase()); + const values = value.$in.map(v => + String(v).toLocaleLowerCase('en-US'), + ); const matchQuery = knex('search') .select('search.entity_id') .where({ key: normalizedKey }) @@ -210,7 +212,7 @@ function applyPredicateInStrategy( this.andWhere(onEntityIdField, 'in', matchQuery); } else if (isHasPrefixValue(value)) { // Handle $hasPrefix - const prefix = value.$hasPrefix.toLowerCase(); + const prefix = value.$hasPrefix.toLocaleLowerCase('en-US'); const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`); const matchQuery = knex('search') .select('search.entity_id') @@ -227,7 +229,7 @@ function applyPredicateInStrategy( .select('search.entity_id') .where({ key: normalizedKey, - value: String(value).toLowerCase(), + value: String(value).toLocaleLowerCase('en-US'), }); this.andWhere(onEntityIdField, 'in', matchQuery); } else { From fa4e24d75265bc3267815f82cc87e887f08dd741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 11:04:28 +0100 Subject: [PATCH 018/118] remove the unnecessary inversion boolean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../applyPredicateEntityFilterToQuery.ts | 200 ++++++++---------- 1 file changed, 86 insertions(+), 114 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 91b29b6ce1..eb6e65604a 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -115,60 +115,55 @@ function isFieldExpression(filter: FilterPredicate): boolean { * ``` */ -function applyPredicateInStrategy( - filter: FilterPredicate, - targetQuery: Knex.QueryBuilder, - onEntityIdField: string, - knex: Knex, - negate: boolean, -): Knex.QueryBuilder { +export function applyPredicateEntityFilterToQuery(options: { + filter: FilterPredicate; + targetQuery: Knex.QueryBuilder; + onEntityIdField: string; + knex: Knex; +}): Knex.QueryBuilder { + const { filter, targetQuery, onEntityIdField, knex } = options; // Handle $not if (isNotPredicate(filter)) { - return applyPredicateInStrategy( - filter.$not, - targetQuery, - onEntityIdField, - knex, - !negate, + return targetQuery.andWhereNot(subQuery => + applyPredicateEntityFilterToQuery({ + filter: filter.$not, + targetQuery: subQuery, + onEntityIdField, + knex, + }), ); } // Handle $all (AND) if (isAllPredicate(filter)) { - return targetQuery[negate ? 'andWhereNot' : 'andWhere']( - function allFilter() { - for (const subFilter of filter.$all) { - this.andWhere(subQuery => - applyPredicateInStrategy( - subFilter, - subQuery, - onEntityIdField, - knex, - false, - ), - ); - } - }, - ); + return targetQuery.andWhere(function allFilter() { + for (const subFilter of filter.$all) { + this.andWhere(subQuery => + applyPredicateEntityFilterToQuery({ + filter: subFilter, + targetQuery: subQuery, + onEntityIdField, + knex, + }), + ); + } + }); } // Handle $any (OR) if (isAnyPredicate(filter)) { - return targetQuery[negate ? 'andWhereNot' : 'andWhere']( - function anyFilter() { - for (const subFilter of filter.$any) { - this.orWhere(subQuery => - applyPredicateInStrategy( - subFilter, - subQuery, - onEntityIdField, - knex, - false, - ), - ); - } - }, - ); + return targetQuery.andWhere(function anyFilter() { + for (const subFilter of filter.$any) { + this.orWhere(subQuery => + applyPredicateEntityFilterToQuery({ + filter: subFilter, + targetQuery: subQuery, + onEntityIdField, + knex, + }), + ); + } + }); } // Reject primitives at the top level. Matching by value without specifying @@ -184,83 +179,60 @@ function applyPredicateInStrategy( // Handle field expressions like { "kind": "component" } or { "spec.type": { "$in": ["service", "website"] } } if (isFieldExpression(filter)) { - return targetQuery[negate ? 'andWhereNot' : 'andWhere']( - function fieldFilter() { - for (const [key, value] of Object.entries(filter)) { - const normalizedKey = key.toLocaleLowerCase('en-US'); + return targetQuery.andWhere(function fieldFilter() { + for (const [key, value] of Object.entries(filter)) { + const normalizedKey = key.toLocaleLowerCase('en-US'); - if (isExistsValue(value)) { - // Handle $exists - const existsQuery = knex('search') - .select('search.entity_id') - .where({ key: normalizedKey }); + if (isExistsValue(value)) { + // Handle $exists + const existsQuery = knex('search') + .select('search.entity_id') + .where({ key: normalizedKey }); - if (value.$exists) { - this.andWhere(onEntityIdField, 'in', existsQuery); - } else { - this.andWhere(onEntityIdField, 'not in', existsQuery); - } - } else if (isInValue(value)) { - // Handle $in - const values = value.$in.map(v => - String(v).toLocaleLowerCase('en-US'), - ); - const matchQuery = knex('search') - .select('search.entity_id') - .where({ key: normalizedKey }) - .whereIn('value', values); - this.andWhere(onEntityIdField, 'in', matchQuery); - } else if (isHasPrefixValue(value)) { - // Handle $hasPrefix - const prefix = value.$hasPrefix.toLocaleLowerCase('en-US'); - const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`); - const matchQuery = knex('search') - .select('search.entity_id') - .where({ key: normalizedKey }) - .andWhereRaw('?? like ? escape ?', [ - 'value', - `${escaped}%`, - '\\', - ]); - this.andWhere(onEntityIdField, 'in', matchQuery); - } else if (isPrimitive(value)) { - // Handle direct value match - const matchQuery = knex('search') - .select('search.entity_id') - .where({ - key: normalizedKey, - value: String(value).toLocaleLowerCase('en-US'), - }); - this.andWhere(onEntityIdField, 'in', matchQuery); + if (value.$exists) { + this.andWhere(onEntityIdField, 'in', existsQuery); } else { - // Reject unsupported/invalid predicate values - throw new InputError( - `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${JSON.stringify( - value, - )}`, - ); + this.andWhere(onEntityIdField, 'not in', existsQuery); } + } else if (isInValue(value)) { + // Handle $in + const values = value.$in.map(v => + String(v).toLocaleLowerCase('en-US'), + ); + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key: normalizedKey }) + .whereIn('value', values); + this.andWhere(onEntityIdField, 'in', matchQuery); + } else if (isHasPrefixValue(value)) { + // Handle $hasPrefix + const prefix = value.$hasPrefix.toLocaleLowerCase('en-US'); + const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`); + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key: normalizedKey }) + .andWhereRaw('?? like ? escape ?', ['value', `${escaped}%`, '\\']); + this.andWhere(onEntityIdField, 'in', matchQuery); + } else if (isPrimitive(value)) { + // Handle direct value match + const matchQuery = knex('search') + .select('search.entity_id') + .where({ + key: normalizedKey, + value: String(value).toLocaleLowerCase('en-US'), + }); + this.andWhere(onEntityIdField, 'in', matchQuery); + } else { + // Reject unsupported/invalid predicate values + throw new InputError( + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${JSON.stringify( + value, + )}`, + ); } - }, - ); + } + }); } return targetQuery; } - -export function applyPredicateEntityFilterToQuery(options: { - filter: FilterPredicate; - targetQuery: Knex.QueryBuilder; - onEntityIdField: string; - knex: Knex; -}): Knex.QueryBuilder { - const { filter, targetQuery, onEntityIdField, knex } = options; - - return applyPredicateInStrategy( - filter, - targetQuery, - onEntityIdField, - knex, - false, - ); -} From b218fb07df3c5475e0dc50d4db2078a756c446c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 11:30:45 +0100 Subject: [PATCH 019/118] arrow functions and simplify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../applyPredicateEntityFilterToQuery.test.ts | 18 ++ .../applyPredicateEntityFilterToQuery.ts | 265 ++++++++---------- 2 files changed, 141 insertions(+), 142 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index 2f216fd219..21e5d2c37a 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -116,6 +116,16 @@ describe.each(databases.eachSupportedId())( ); } + it('matches everything for empty field expression', async () => { + await expect(query({})).resolves.toEqual([ + 'api-d', + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + it('filters by direct field value', async () => { await expect(query({ kind: 'component' })).resolves.toEqual([ 'bare-e', @@ -140,6 +150,10 @@ describe.each(databases.eachSupportedId())( ).resolves.toEqual(['service-a', 'service-b']); }); + it('returns nothing for empty $all', async () => { + await expect(query({ $all: [] })).resolves.toEqual([]); + }); + it('filters with $any', async () => { await expect( query({ @@ -148,6 +162,10 @@ describe.each(databases.eachSupportedId())( ).resolves.toEqual(['service-a', 'service-b', 'website-c']); }); + it('returns nothing for empty $any', async () => { + await expect(query({ $any: [] })).resolves.toEqual([]); + }); + it('filters with $not', async () => { await expect( query({ diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index eb6e65604a..88b064866d 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -23,24 +23,6 @@ import { InputError } from '@backstage/errors'; import { Knex } from 'knex'; import { DbSearchRow } from '../../database/tables'; -function isAllPredicate( - filter: FilterPredicate, -): filter is { $all: FilterPredicate[] } { - return typeof filter === 'object' && filter !== null && '$all' in filter; -} - -function isAnyPredicate( - filter: FilterPredicate, -): filter is { $any: FilterPredicate[] } { - return typeof filter === 'object' && filter !== null && '$any' in filter; -} - -function isNotPredicate( - filter: FilterPredicate, -): filter is { $not: FilterPredicate } { - return typeof filter === 'object' && filter !== null && '$not' in filter; -} - function isPrimitive(value: unknown): value is FilterPredicatePrimitive { return ( typeof value === 'string' || @@ -49,30 +31,8 @@ function isPrimitive(value: unknown): value is FilterPredicatePrimitive { ); } -function isExistsValue( - value: FilterPredicateValue, -): value is { $exists: boolean } { - return typeof value === 'object' && value !== null && '$exists' in value; -} - -function isInValue( - value: FilterPredicateValue, -): value is { $in: FilterPredicatePrimitive[] } { - return typeof value === 'object' && value !== null && '$in' in value; -} - -function isHasPrefixValue( - value: FilterPredicateValue, -): value is { $hasPrefix: string } { - return typeof value === 'object' && value !== null && '$hasPrefix' in value; -} - -function isFieldExpression(filter: FilterPredicate): boolean { - if (typeof filter !== 'object' || filter === null) { - return false; - } - // Not a logical operator - return !('$all' in filter || '$any' in filter || '$not' in filter); +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); } /** @@ -114,7 +74,6 @@ function isFieldExpression(filter: FilterPredicate): boolean { * ORDER BY final_entities.entity_ref ASC; * ``` */ - export function applyPredicateEntityFilterToQuery(options: { filter: FilterPredicate; targetQuery: Knex.QueryBuilder; @@ -122,53 +81,9 @@ export function applyPredicateEntityFilterToQuery(options: { knex: Knex; }): Knex.QueryBuilder { const { filter, targetQuery, onEntityIdField, knex } = options; - // Handle $not - if (isNotPredicate(filter)) { - return targetQuery.andWhereNot(subQuery => - applyPredicateEntityFilterToQuery({ - filter: filter.$not, - targetQuery: subQuery, - onEntityIdField, - knex, - }), - ); - } - // Handle $all (AND) - if (isAllPredicate(filter)) { - return targetQuery.andWhere(function allFilter() { - for (const subFilter of filter.$all) { - this.andWhere(subQuery => - applyPredicateEntityFilterToQuery({ - filter: subFilter, - targetQuery: subQuery, - onEntityIdField, - knex, - }), - ); - } - }); - } - - // Handle $any (OR) - if (isAnyPredicate(filter)) { - return targetQuery.andWhere(function anyFilter() { - for (const subFilter of filter.$any) { - this.orWhere(subQuery => - applyPredicateEntityFilterToQuery({ - filter: subFilter, - targetQuery: subQuery, - onEntityIdField, - knex, - }), - ); - } - }); - } - - // Reject primitives at the top level. Matching by value without specifying - // a field key is ambiguous and should not be allowed. - if (isPrimitive(filter)) { + // We do not support top-level primitives; all matching happens through objects + if (!filter || typeof filter !== 'object' || Array.isArray(filter)) { throw new InputError( `Invalid filter predicate: top-level primitive values are not supported. ` + `Wrap the value in a field expression, e.g. { "kind": ${JSON.stringify( @@ -177,62 +92,128 @@ export function applyPredicateEntityFilterToQuery(options: { ); } - // Handle field expressions like { "kind": "component" } or { "spec.type": { "$in": ["service", "website"] } } - if (isFieldExpression(filter)) { - return targetQuery.andWhere(function fieldFilter() { - for (const [key, value] of Object.entries(filter)) { - const normalizedKey = key.toLocaleLowerCase('en-US'); + if ('$not' in filter) { + return targetQuery.andWhereNot(inner => + applyPredicateEntityFilterToQuery({ + filter: filter.$not, + targetQuery: inner, + onEntityIdField, + knex, + }), + ); + } - if (isExistsValue(value)) { - // Handle $exists - const existsQuery = knex('search') - .select('search.entity_id') - .where({ key: normalizedKey }); - - if (value.$exists) { - this.andWhere(onEntityIdField, 'in', existsQuery); - } else { - this.andWhere(onEntityIdField, 'not in', existsQuery); - } - } else if (isInValue(value)) { - // Handle $in - const values = value.$in.map(v => - String(v).toLocaleLowerCase('en-US'), - ); - const matchQuery = knex('search') - .select('search.entity_id') - .where({ key: normalizedKey }) - .whereIn('value', values); - this.andWhere(onEntityIdField, 'in', matchQuery); - } else if (isHasPrefixValue(value)) { - // Handle $hasPrefix - const prefix = value.$hasPrefix.toLocaleLowerCase('en-US'); - const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`); - const matchQuery = knex('search') - .select('search.entity_id') - .where({ key: normalizedKey }) - .andWhereRaw('?? like ? escape ?', ['value', `${escaped}%`, '\\']); - this.andWhere(onEntityIdField, 'in', matchQuery); - } else if (isPrimitive(value)) { - // Handle direct value match - const matchQuery = knex('search') - .select('search.entity_id') - .where({ - key: normalizedKey, - value: String(value).toLocaleLowerCase('en-US'), - }); - this.andWhere(onEntityIdField, 'in', matchQuery); - } else { - // Reject unsupported/invalid predicate values - throw new InputError( - `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${JSON.stringify( - value, - )}`, - ); - } + if ('$all' in filter) { + if (filter.$all.length === 0) { + return targetQuery.andWhereRaw('1 = 0'); + } + return targetQuery.andWhere(outer => { + for (const subFilter of filter.$all) { + outer.andWhere(inner => + applyPredicateEntityFilterToQuery({ + filter: subFilter, + targetQuery: inner, + onEntityIdField, + knex, + }), + ); } }); } - return targetQuery; + if ('$any' in filter) { + if (filter.$any.length === 0) { + return targetQuery.andWhereRaw('1 = 0'); + } + return targetQuery.andWhere(outer => { + for (const subFilter of filter.$any) { + outer.orWhere(inner => + applyPredicateEntityFilterToQuery({ + filter: subFilter, + targetQuery: inner, + onEntityIdField, + knex, + }), + ); + } + }); + } + + // Treat the filter as a field expression like { "kind": "component" } or { "spec.type": { "$in": ["service", "website"] } } + if (Object.keys(filter).length === 0) { + return targetQuery; + } + return targetQuery.andWhere(inner => { + for (const [keyAnyCase, value] of Object.entries(filter)) { + applyFieldCondition({ + key: keyAnyCase.toLocaleLowerCase('en-US'), + value, + targetQuery: inner, + onEntityIdField, + knex, + }); + } + }); +} + +/** + * Applies a single { key: value } filter to the target query. + */ +function applyFieldCondition(options: { + key: string; + value: FilterPredicateValue; + targetQuery: Knex.QueryBuilder; + onEntityIdField: string; + knex: Knex; +}): Knex.QueryBuilder { + const { key, value, targetQuery, onEntityIdField, knex } = options; + + if (isPrimitive(value)) { + const matchQuery = knex('search') + .select('search.entity_id') + .where({ + key, + value: String(value).toLocaleLowerCase('en-US'), + }); + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); + } + + if (isObject(value)) { + if ('$exists' in value) { + const existsQuery = knex('search') + .select('search.entity_id') + .where({ key }); + if (value.$exists) { + return targetQuery.andWhere(onEntityIdField, 'in', existsQuery); + } + return targetQuery.andWhere(onEntityIdField, 'not in', existsQuery); + } + + if ('$in' in value) { + const values = (value.$in as FilterPredicatePrimitive[]).map(v => + String(v).toLocaleLowerCase('en-US'), + ); + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key }) + .whereIn('value', values); + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); + } + + if ('$hasPrefix' in value) { + const prefix = (value.$hasPrefix as string).toLocaleLowerCase('en-US'); + const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`); + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key }) + .andWhereRaw('?? like ? escape ?', ['value', `${escaped}%`, '\\']); + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); + } + } + + throw new InputError( + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${JSON.stringify( + value, + )}`, + ); } From 59d458202033be0d15c742a6559be75155c6a441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 11:33:08 +0100 Subject: [PATCH 020/118] remove extra spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/catalog-backend/src/service/createRouter.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 4571d3bc68..ebfadc66c7 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -72,7 +72,6 @@ describe('createRouter readonly disabled', () => { beforeEach(async () => { entitiesCatalog = { entities: jest.fn(), - entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), @@ -1177,7 +1176,6 @@ describe('createRouter readonly and raw json enabled', () => { beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), - entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), @@ -1394,7 +1392,6 @@ describe('NextRouter permissioning', () => { beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), - entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), From 42d89b5025b4372c2deeba39a0268f826be32e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 11:47:51 +0100 Subject: [PATCH 021/118] small fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/rich-ducks-ring.md | 2 +- .../src/service/request/applyPredicateEntityFilterToQuery.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/rich-ducks-ring.md b/.changeset/rich-ducks-ring.md index 04848da4a7..ea8a3e8016 100644 --- a/.changeset/rich-ducks-ring.md +++ b/.changeset/rich-ducks-ring.md @@ -5,6 +5,6 @@ Added predicate-based entity filtering via POST /entities/by-query endpoint. -Supports `$all`, `$any`, `$not`, `$exists`, and `$in` operators for expressive entity queries. Integrated into the existing `queryEntities` flow with full cursor-based pagination, permission enforcement, and `totalItems` support. +Supports `$all`, `$any`, `$not`, `$exists`, `$in`, and `$hasPrefix` operators for expressive entity queries. Integrated into the existing `queryEntities` flow with full cursor-based pagination, permission enforcement, and `totalItems` support. The catalog client's `queryEntities()` method automatically routes to the POST endpoint when a `query` predicate is provided. diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 88b064866d..5deb97019c 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. @@ -83,7 +83,7 @@ export function applyPredicateEntityFilterToQuery(options: { const { filter, targetQuery, onEntityIdField, knex } = options; // We do not support top-level primitives; all matching happens through objects - if (!filter || typeof filter !== 'object' || Array.isArray(filter)) { + if (!isObject(filter)) { throw new InputError( `Invalid filter predicate: top-level primitive values are not supported. ` + `Wrap the value in a field expression, e.g. { "kind": ${JSON.stringify( From 06ac10a0c27a34a53c1e1ed17937e7dd350a21cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 11:53:33 +0100 Subject: [PATCH 022/118] make sure to pass through offset too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/catalog-client/src/CatalogClient.ts | 6 ++++- .../QueryEntitiesByPredicateRequest.model.ts | 1 + .../catalog-backend/src/schema/openapi.yaml | 2 ++ .../QueryEntitiesByPredicateRequest.model.ts | 1 + .../src/schema/openapi/generated/router.ts | 3 +++ .../src/service/createRouter.test.ts | 25 +++++++++++++++++++ .../service/request/parseEntityQuery.test.ts | 13 ++++++++++ .../src/service/request/parseEntityQuery.ts | 1 + 8 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index fa8a88c1ca..62111abf9e 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -359,13 +359,17 @@ export class CatalogClient implements CatalogApi { const body: QueryEntitiesByPredicateRequest = {}; if (isQueryEntitiesInitialRequest(request)) { - const { query, limit, orderFields, fullTextFilter, fields } = request; + const { query, limit, offset, orderFields, fullTextFilter, fields } = + request; if (query && typeof query === 'object') { body.query = query; } if (limit !== undefined) { body.limit = limit; } + if (offset !== undefined) { + body.offset = offset; + } if (orderFields !== undefined) { body.orderBy = [orderFields].flat(); } diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index 818a780b4a..17a9c9962f 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -27,6 +27,7 @@ import { QueryEntitiesByPredicateRequestOrderByInner } from '../models/QueryEnti export interface QueryEntitiesByPredicateRequest { cursor?: string; limit?: number; + offset?: number; orderBy?: Array; fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter; fields?: Array; diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index 41738c1148..0531a9a1d3 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -1125,6 +1125,8 @@ paths: type: string limit: type: number + offset: + type: number orderBy: type: array items: diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index 818a780b4a..17a9c9962f 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -27,6 +27,7 @@ import { QueryEntitiesByPredicateRequestOrderByInner } from '../models/QueryEnti export interface QueryEntitiesByPredicateRequest { cursor?: string; limit?: number; + offset?: number; orderBy?: Array; fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter; fields?: Array; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index ed9536d6b9..6c739eb2ee 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -1269,6 +1269,9 @@ export const spec = { limit: { type: 'number', }, + offset: { + type: 'number', + }, orderBy: { type: 'array', items: { diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index ebfadc66c7..acfac3b8d1 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -509,6 +509,31 @@ describe('createRouter readonly disabled', () => { ); }); + it('queries entities with an offset', async () => { + const items: Entity[] = [ + { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, + ]; + entitiesCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities: items }, + pageInfo: {}, + totalItems: 5, + }); + + const response = await request(app) + .post('/entities/by-query') + .send({ query: { kind: 'b' }, limit: 2, offset: 3 }); + + expect(response.status).toEqual(200); + expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ + query: { kind: 'b' }, + limit: 2, + offset: 3, + credentials: mockCredentials.user(), + }), + ); + }); + it('paginates with a cursor in the body', async () => { const items: Entity[] = [ { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts index b95c4c6c0e..7380259188 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts @@ -28,6 +28,7 @@ describe('parseEntityQuery', () => { fullTextFilter: undefined, fields: undefined, limit: undefined, + offset: undefined, }); }); @@ -68,6 +69,18 @@ describe('parseEntityQuery', () => { expect(result).toEqual(expect.objectContaining({ limit: 50 })); }); + it('passes through offset', () => { + const result = parseEntityQuery({ offset: 100 }); + expect(result).toEqual(expect.objectContaining({ offset: 100 })); + }); + + it('passes through limit and offset together', () => { + const result = parseEntityQuery({ limit: 50, offset: 100 }); + expect(result).toEqual( + expect.objectContaining({ limit: 50, offset: 100 }), + ); + }); + it('passes through fields', () => { const result = parseEntityQuery({ fields: ['metadata.name', 'kind'], diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts index c33492e740..27992c273a 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityQuery.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts @@ -110,5 +110,6 @@ export function parseEntityQuery( : undefined, fields: request.fields, limit: request.limit, + offset: request.offset, }; } From b8355e72089ecfac9202913a7d5b2ffb079247cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 12:09:38 +0100 Subject: [PATCH 023/118] empty should match everything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../request/applyPredicateEntityFilterToQuery.test.ts | 10 ++++++++-- .../request/applyPredicateEntityFilterToQuery.ts | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index 21e5d2c37a..70d5461c3a 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -150,8 +150,14 @@ describe.each(databases.eachSupportedId())( ).resolves.toEqual(['service-a', 'service-b']); }); - it('returns nothing for empty $all', async () => { - await expect(query({ $all: [] })).resolves.toEqual([]); + it('matches everything for empty $all', async () => { + await expect(query({ $all: [] })).resolves.toEqual([ + 'api-d', + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); }); it('filters with $any', async () => { diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 5deb97019c..2a404b88bf 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -105,7 +105,7 @@ export function applyPredicateEntityFilterToQuery(options: { if ('$all' in filter) { if (filter.$all.length === 0) { - return targetQuery.andWhereRaw('1 = 0'); + return targetQuery; } return targetQuery.andWhere(outer => { for (const subFilter of filter.$all) { From 430b8978402961929506df10fbff31a5469eff72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 12:14:08 +0100 Subject: [PATCH 024/118] add explicit clear rejection of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../request/applyPredicateEntityFilterToQuery.test.ts | 6 ++++++ .../service/request/applyPredicateEntityFilterToQuery.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index 70d5461c3a..b90e0ca409 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -231,6 +231,12 @@ describe.each(databases.eachSupportedId())( ).resolves.toEqual(['service-a', 'website-c']); }); + it('throws on $contains operator', async () => { + await expect( + query({ 'metadata.name': { $contains: 'service' } as any }), + ).rejects.toThrow(/\$contains operator is not supported/); + }); + it('throws on top-level primitive', async () => { await expect(query('bad-value' as any)).rejects.toThrow( /top-level primitive values are not supported/, diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 2a404b88bf..56d85a9da1 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -209,6 +209,11 @@ function applyFieldCondition(options: { .andWhereRaw('?? like ? escape ?', ['value', `${escaped}%`, '\\']); return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); } + + if ('$contains' in value) { + // TODO(freben): Implement this, AT LEAST for some special cases such as tags and relations. + throw new InputError('The $contains operator is not supported'); + } } throw new InputError( From 9dd2f112f0a920531987e31f9be67773f9143737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 12:17:57 +0100 Subject: [PATCH 025/118] add more pagination tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../catalog-client/src/CatalogClient.test.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index aee10cd3f2..7ce9e94396 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -807,6 +807,81 @@ describe('CatalogClient', () => { expect(mockedEndpoint).toHaveBeenCalledTimes(1); }); + it('should paginate using POST when cursor contains a query', async () => { + // Simulate a cursor that contains a query predicate (as the server would encode it) + const cursorPayload = Buffer.from( + JSON.stringify({ + orderFields: [], + orderFieldValues: [], + isPrevious: false, + query: { kind: 'component' }, + totalItems: 100, + }), + ).toString('base64'); + + const page2Response = { + items: [ + { + apiVersion: '1', + kind: 'Component', + metadata: { name: 'service-3', namespace: 'default' }, + }, + ], + totalItems: 100, + pageInfo: {}, + }; + + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.method).toBe('POST'); + expect(req.body).toMatchObject({ cursor: cursorPayload }); + return res(ctx.json(page2Response)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + const response = await client.queryEntities({ + cursor: cursorPayload, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + expect(response.items).toEqual(page2Response.items); + expect(response.totalItems).toBe(100); + }); + + it('should use GET endpoint for cursor without query', async () => { + // A cursor that does NOT contain a query field should go to GET + const cursorPayload = Buffer.from( + JSON.stringify({ + orderFields: [], + orderFieldValues: [], + isPrevious: false, + totalItems: 50, + }), + ).toString('base64'); + + const mockedGetEndpoint = jest.fn().mockImplementation((_req, res, ctx) => + res( + ctx.json({ + items: [], + totalItems: 50, + pageInfo: {}, + }), + ), + ); + + const mockedPostEndpoint = jest.fn(); + + server.use( + rest.get(`${mockBaseUrl}/entities/by-query`, mockedGetEndpoint), + rest.post(`${mockBaseUrl}/entities/by-query`, mockedPostEndpoint), + ); + + await client.queryEntities({ cursor: cursorPayload }); + + expect(mockedGetEndpoint).toHaveBeenCalledTimes(1); + expect(mockedPostEndpoint).not.toHaveBeenCalled(); + }); + it('should handle errors from POST endpoint', async () => { const mockedEndpoint = jest .fn() From 1ed51c2d8a7641b052529d7311299ec70cabae57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 13:34:06 +0100 Subject: [PATCH 026/118] make sure that permissions apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../service/AuthorizedEntitiesCatalog.test.ts | 172 ++++++++++++++++++ .../src/service/AuthorizedEntitiesCatalog.ts | 64 ++++++- .../applyPredicateEntityFilterToQuery.ts | 2 +- 3 files changed, 231 insertions(+), 7 deletions(-) diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index 894949a691..f074f25397 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -23,6 +23,7 @@ import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog'; import { Cursor, QueryEntitiesResponse } from '../catalog/types'; import { Entity } from '@backstage/catalog-model'; import { EntityFilter } from '@backstage/plugin-catalog-node'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { mockCredentials } from '@backstage/backend-test-utils'; describe('AuthorizedEntitiesCatalog', () => { @@ -306,6 +307,177 @@ describe('AuthorizedEntitiesCatalog', () => { }, }); }); + + it('combines permission filter into query field using $all on CONDITIONAL with initial request', async () => { + fakePermissionApi.authorizeConditional.mockResolvedValue([ + { + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_KIND', + params: { kinds: ['b'] }, + }, + }, + ]); + + const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' }; + + const entities = [ + { + kind: 'component', + namespace: 'default', + name: 'a', + } as unknown as Entity, + ]; + + fakeCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities }, + pageInfo: { + nextCursor: { + isPrevious: false, + orderFieldValues: ['xxx', null], + query: { $all: [{ kind: 'b' }, userQuery] }, + orderFields: [{ field: 'name', order: 'asc' }], + }, + }, + totalItems: 1, + } as QueryEntitiesResponse); + + const catalog = createCatalog(isEntityKind); + + const response = await catalog.queryEntities({ + credentials: mockCredentials.none(), + query: userQuery, + }); + + expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ + credentials: mockCredentials.none(), + query: { $all: [{ kind: 'b' }, userQuery] }, + filter: undefined, + }); + + expect(response.pageInfo.nextCursor).toEqual({ + isPrevious: false, + orderFieldValues: ['xxx', null], + query: userQuery, + filter: undefined, + orderFields: [{ field: 'name', order: 'asc' }], + }); + }); + + it('combines permission filter into cursor query field using $all on CONDITIONAL with cursor request', async () => { + fakePermissionApi.authorizeConditional.mockResolvedValue([ + { + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_KIND', + params: { kinds: ['b'] }, + }, + }, + ]); + + const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' }; + + const entities = [ + { + kind: 'component', + namespace: 'default', + name: 'a', + } as unknown as Entity, + ]; + + fakeCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities }, + pageInfo: { + nextCursor: { + isPrevious: false, + orderFieldValues: ['yyy', null], + query: { $all: [{ kind: 'b' }, userQuery] }, + orderFields: [{ field: 'name', order: 'asc' }], + }, + prevCursor: { + isPrevious: true, + orderFieldValues: ['aaa', null], + query: { $all: [{ kind: 'b' }, userQuery] }, + orderFields: [{ field: 'name', order: 'asc' }], + }, + }, + totalItems: 3, + } as QueryEntitiesResponse); + + const catalog = createCatalog(isEntityKind); + + const cursor: Cursor = { + query: userQuery, + orderFields: [{ field: 'name', order: 'asc' }], + isPrevious: false, + orderFieldValues: ['xxx', null], + }; + + const response = await catalog.queryEntities({ + credentials: mockCredentials.none(), + cursor, + }); + + expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ + credentials: mockCredentials.none(), + cursor: { + ...cursor, + query: { $all: [{ kind: 'b' }, userQuery] }, + filter: undefined, + }, + }); + + expect(response.pageInfo.nextCursor).toEqual({ + isPrevious: false, + orderFieldValues: ['yyy', null], + query: userQuery, + filter: undefined, + orderFields: [{ field: 'name', order: 'asc' }], + }); + + expect(response.pageInfo.prevCursor).toEqual({ + isPrevious: true, + orderFieldValues: ['aaa', null], + query: userQuery, + filter: undefined, + orderFields: [{ field: 'name', order: 'asc' }], + }); + }); + + it('converts multi-value permission filter with $in when converting to predicate', async () => { + fakePermissionApi.authorizeConditional.mockResolvedValue([ + { + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_KIND', + params: { kinds: ['component', 'api'] }, + }, + }, + ]); + + const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' }; + + fakeCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities: [] }, + pageInfo: {}, + totalItems: 0, + } as QueryEntitiesResponse); + + const catalog = createCatalog(isEntityKind); + + await catalog.queryEntities({ + credentials: mockCredentials.none(), + query: userQuery, + }); + + expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ + credentials: mockCredentials.none(), + query: { + $all: [{ kind: { $in: ['component', 'api'] } }, userQuery], + }, + filter: undefined, + }); + }); }); describe('removeEntityByUid', () => { diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index d3d0dba5b3..bb81144f25 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -35,6 +35,7 @@ import { QueryEntitiesRequest, QueryEntitiesResponse, } from '../catalog/types'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { basicEntityFilter } from './request'; import { isQueryEntitiesCursorRequest } from './util'; import { EntityFilter } from '@backstage/plugin-catalog-node'; @@ -43,6 +44,30 @@ import { PermissionsService, } from '@backstage/backend-plugin-api'; +function entityFilterToFilterPredicate(filter: EntityFilter): FilterPredicate { + if ('allOf' in filter) { + return { $all: filter.allOf.map(entityFilterToFilterPredicate) }; + } + + if ('anyOf' in filter) { + return { $any: filter.anyOf.map(entityFilterToFilterPredicate) }; + } + + if ('not' in filter) { + return { $not: entityFilterToFilterPredicate(filter.not) }; + } + + if (!filter.values) { + return { [filter.key]: { $exists: true } } as FilterPredicate; + } + + if (filter.values.length === 1) { + return { [filter.key]: filter.values[0] } as FilterPredicate; + } + + return { [filter.key]: { $in: filter.values } } as FilterPredicate; +} + export class AuthorizedEntitiesCatalog implements EntitiesCatalog { private readonly entitiesCatalog: EntitiesCatalog; private readonly permissionApi: PermissionsService; @@ -147,18 +172,42 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { let permissionedRequest: QueryEntitiesRequest; let requestFilter: EntityFilter | undefined; + let requestQuery: FilterPredicate | undefined; if (isQueryEntitiesCursorRequest(request)) { requestFilter = request.cursor.filter; + requestQuery = request.cursor.query; + if (request.cursor.query) { + const permissionPredicate = + entityFilterToFilterPredicate(permissionFilter); + permissionedRequest = { + ...request, + cursor: { + ...request.cursor, + query: { $all: [permissionPredicate, request.cursor.query] }, + filter: undefined, + }, + }; + } else { + permissionedRequest = { + ...request, + cursor: { + ...request.cursor, + filter: request.cursor.filter + ? { allOf: [permissionFilter, request.cursor.filter] } + : permissionFilter, + }, + }; + } + } else if (request.query) { + const permissionPredicate = + entityFilterToFilterPredicate(permissionFilter); + requestQuery = request.query; permissionedRequest = { ...request, - cursor: { - ...request.cursor, - filter: request.cursor.filter - ? { allOf: [permissionFilter, request.cursor.filter] } - : permissionFilter, - }, + query: { $all: [permissionPredicate, request.query] }, + filter: undefined, }; } else { permissionedRequest = { @@ -177,11 +226,13 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { const prevCursor: Cursor | undefined = response.pageInfo.prevCursor && { ...response.pageInfo.prevCursor, filter: requestFilter, + query: requestQuery, }; const nextCursor: Cursor | undefined = response.pageInfo.nextCursor && { ...response.pageInfo.nextCursor, filter: requestFilter, + query: requestQuery, }; return { @@ -193,6 +244,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { }; } + // The ALLOW case return this.entitiesCatalog.queryEntities(request); } diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 56d85a9da1..b42252369d 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -105,7 +105,7 @@ export function applyPredicateEntityFilterToQuery(options: { if ('$all' in filter) { if (filter.$all.length === 0) { - return targetQuery; + return targetQuery.andWhereRaw('1 = 1'); } return targetQuery.andWhere(outer => { for (const subFilter of filter.$all) { From 345a36aa1b3f549546122ad2e86f51312af05f11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 13:57:11 +0100 Subject: [PATCH 027/118] remove the exclusivity constraint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../catalog-client/src/CatalogClient.test.ts | 11 ---- packages/catalog-client/src/CatalogClient.ts | 7 --- packages/catalog-client/src/types/api.ts | 11 +++- plugins/catalog-backend/src/catalog/types.ts | 2 - .../service/AuthorizedEntitiesCatalog.test.ts | 55 ++++------------ .../src/service/AuthorizedEntitiesCatalog.ts | 62 +++---------------- .../service/DefaultEntitiesCatalog.test.ts | 32 ++++++++++ 7 files changed, 60 insertions(+), 120 deletions(-) diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 7ce9e94396..819fa70381 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -594,17 +594,6 @@ describe('CatalogClient', () => { expect(response.totalItems).toBe(2); }); - it('should throw error when both filter and query are provided', async () => { - await expect( - client.queryEntities({ - filter: { kind: 'component' }, - query: { kind: 'component' }, - } as any), - ).rejects.toThrow( - 'Cannot specify both "filter" and "query" in the same request', - ); - }); - it('should support $all operator', async () => { const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { expect(req.body).toMatchObject({ diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 62111abf9e..bd557cfdd2 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -274,13 +274,6 @@ export class CatalogClient implements CatalogApi { ): Promise { const isInitialRequest = isQueryEntitiesInitialRequest(request); - // Validate that filter and query are mutually exclusive - if (isInitialRequest && request.filter && request.query) { - throw new Error( - 'Cannot specify both "filter" and "query" in the same request. Use "filter" for traditional key-value filtering or "query" for predicate-based filtering.', - ); - } - // Route to POST endpoint if query predicate is provided (initial request) if (isInitialRequest && request.query) { return this.queryEntitiesByPredicate(request, options); diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 8bf13271b2..2dffb35570 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -593,6 +593,7 @@ export interface CatalogApi { * const response = await catalogClient.queryEntities({ * filter: [{ kind: 'group' }], * limit: 20, + * fields: ['metadata', 'kind'], * fullTextFilter: { * term: 'A', * }, @@ -609,11 +610,15 @@ export interface CatalogApi { * * ``` * const secondBatchResponse = await catalogClient - * .queryEntities({ cursor: response.nextCursor }); + * .queryEntities({ + * cursor: response.nextCursor, + * limit: 20, + * fields: ['metadata', 'kind'], + * }); * ``` * - * secondBatchResponse will contain the next batch of (maximum) 20 entities, - * together with a prevCursor property, useful to fetch the previous batch. + * `secondBatchResponse` will contain the next batch of (maximum) 20 entities, + * together with a `prevCursor` property, useful to fetch the previous batch. * * @public * diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index dda7e9a811..5d733ad14c 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -215,7 +215,6 @@ export interface QueryEntitiesInitialRequest { filter?: EntityFilter; /** * Predicate-based query for filtering entities. - * Mutually exclusive with filter. */ query?: FilterPredicate; orderFields?: EntityOrder[]; @@ -281,7 +280,6 @@ export type Cursor = { filter?: EntityFilter; /** * A predicate-based query to be applied to the full list of entities. - * Mutually exclusive with filter. */ query?: FilterPredicate; /** diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index f074f25397..85fbd16eeb 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -308,7 +308,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); }); - it('combines permission filter into query field using $all on CONDITIONAL with initial request', async () => { + it('passes through query alongside permission filter on CONDITIONAL with initial request', async () => { fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, @@ -335,7 +335,8 @@ describe('AuthorizedEntitiesCatalog', () => { nextCursor: { isPrevious: false, orderFieldValues: ['xxx', null], - query: { $all: [{ kind: 'b' }, userQuery] }, + query: userQuery, + filter: { key: 'kind', values: ['b'] }, orderFields: [{ field: 'name', order: 'asc' }], }, }, @@ -351,8 +352,8 @@ describe('AuthorizedEntitiesCatalog', () => { expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ credentials: mockCredentials.none(), - query: { $all: [{ kind: 'b' }, userQuery] }, - filter: undefined, + query: userQuery, + filter: { key: 'kind', values: ['b'] }, }); expect(response.pageInfo.nextCursor).toEqual({ @@ -364,7 +365,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); }); - it('combines permission filter into cursor query field using $all on CONDITIONAL with cursor request', async () => { + it('passes through cursor query alongside permission filter on CONDITIONAL with cursor request', async () => { fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, @@ -391,13 +392,15 @@ describe('AuthorizedEntitiesCatalog', () => { nextCursor: { isPrevious: false, orderFieldValues: ['yyy', null], - query: { $all: [{ kind: 'b' }, userQuery] }, + query: userQuery, + filter: { key: 'kind', values: ['b'] }, orderFields: [{ field: 'name', order: 'asc' }], }, prevCursor: { isPrevious: true, orderFieldValues: ['aaa', null], - query: { $all: [{ kind: 'b' }, userQuery] }, + query: userQuery, + filter: { key: 'kind', values: ['b'] }, orderFields: [{ field: 'name', order: 'asc' }], }, }, @@ -422,8 +425,7 @@ describe('AuthorizedEntitiesCatalog', () => { credentials: mockCredentials.none(), cursor: { ...cursor, - query: { $all: [{ kind: 'b' }, userQuery] }, - filter: undefined, + filter: { key: 'kind', values: ['b'] }, }, }); @@ -443,41 +445,6 @@ describe('AuthorizedEntitiesCatalog', () => { orderFields: [{ field: 'name', order: 'asc' }], }); }); - - it('converts multi-value permission filter with $in when converting to predicate', async () => { - fakePermissionApi.authorizeConditional.mockResolvedValue([ - { - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_KIND', - params: { kinds: ['component', 'api'] }, - }, - }, - ]); - - const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' }; - - fakeCatalog.queryEntities.mockResolvedValue({ - items: { type: 'object', entities: [] }, - pageInfo: {}, - totalItems: 0, - } as QueryEntitiesResponse); - - const catalog = createCatalog(isEntityKind); - - await catalog.queryEntities({ - credentials: mockCredentials.none(), - query: userQuery, - }); - - expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ - credentials: mockCredentials.none(), - query: { - $all: [{ kind: { $in: ['component', 'api'] } }, userQuery], - }, - filter: undefined, - }); - }); }); describe('removeEntityByUid', () => { diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index bb81144f25..910188b036 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -44,30 +44,6 @@ import { PermissionsService, } from '@backstage/backend-plugin-api'; -function entityFilterToFilterPredicate(filter: EntityFilter): FilterPredicate { - if ('allOf' in filter) { - return { $all: filter.allOf.map(entityFilterToFilterPredicate) }; - } - - if ('anyOf' in filter) { - return { $any: filter.anyOf.map(entityFilterToFilterPredicate) }; - } - - if ('not' in filter) { - return { $not: entityFilterToFilterPredicate(filter.not) }; - } - - if (!filter.values) { - return { [filter.key]: { $exists: true } } as FilterPredicate; - } - - if (filter.values.length === 1) { - return { [filter.key]: filter.values[0] } as FilterPredicate; - } - - return { [filter.key]: { $in: filter.values } } as FilterPredicate; -} - export class AuthorizedEntitiesCatalog implements EntitiesCatalog { private readonly entitiesCatalog: EntitiesCatalog; private readonly permissionApi: PermissionsService; @@ -178,45 +154,25 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { requestFilter = request.cursor.filter; requestQuery = request.cursor.query; - if (request.cursor.query) { - const permissionPredicate = - entityFilterToFilterPredicate(permissionFilter); - permissionedRequest = { - ...request, - cursor: { - ...request.cursor, - query: { $all: [permissionPredicate, request.cursor.query] }, - filter: undefined, - }, - }; - } else { - permissionedRequest = { - ...request, - cursor: { - ...request.cursor, - filter: request.cursor.filter - ? { allOf: [permissionFilter, request.cursor.filter] } - : permissionFilter, - }, - }; - } - } else if (request.query) { - const permissionPredicate = - entityFilterToFilterPredicate(permissionFilter); - requestQuery = request.query; permissionedRequest = { ...request, - query: { $all: [permissionPredicate, request.query] }, - filter: undefined, + cursor: { + ...request.cursor, + filter: request.cursor.filter + ? { allOf: [permissionFilter, request.cursor.filter] } + : permissionFilter, + }, }; } else { + requestFilter = request.filter; + requestQuery = request.query; + permissionedRequest = { ...request, filter: request.filter ? { allOf: [permissionFilter, request.filter] } : permissionFilter, }; - requestFilter = request.filter; } const response = await this.entitiesCatalog.queryEntities( diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts index 7012c2e505..f9e41b679c 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts @@ -2054,6 +2054,38 @@ describe('DefaultEntitiesCatalog', () => { ]); }, ); + + it.each(databases.eachSupportedId())( + 'should apply both filter and query when both are given, %p', + async databaseId => { + await createDatabase(databaseId); + + // Add entities with different kinds and names + await addEntityToSearch(entityFrom('A', { kind: 'component' })); + await addEntityToSearch(entityFrom('B', { kind: 'component' })); + await addEntityToSearch(entityFrom('C', { kind: 'api' })); + await addEntityToSearch(entityFrom('D', { kind: 'api' })); + + const catalog = new DefaultEntitiesCatalog({ + database: knex, + logger: mockServices.logger.mock(), + stitcher, + }); + + // Use filter to restrict to kind=component, and query to restrict to name=A + const response = await catalog.queryEntities({ + filter: { key: 'kind', values: ['component'] }, + query: { 'metadata.name': 'a' }, + orderFields: [{ field: 'metadata.name', order: 'asc' }], + credentials: mockCredentials.none(), + }); + + const resultEntities = entitiesResponseToObjects(response.items); + expect(resultEntities).toEqual([ + entityFrom('A', { kind: 'component' }), + ]); + }, + ); }); describe('removeEntityByUid', () => { From 7f0f4139300049969ad727bd629dee1cc1479ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 14:34:22 +0100 Subject: [PATCH 028/118] Update packages/catalog-client/src/types/api.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Fredrik Adelöw --- packages/catalog-client/src/types/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 2dffb35570..a99f36573e 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -438,7 +438,7 @@ export type QueryEntitiesInitialRequest = { */ filter?: EntityFilterQuery; /** - * Predicate-based filter with logical operators ($all, $any, $not, $exists, $in). + * Predicate-based filter with logical operators ($all, $any, $not, $exists, $in, $hasPrefix). * Mutually exclusive with `filter`. * * @example From 7969c48889e4207e21e9a60219fe69b03e5fc663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 14:42:17 +0100 Subject: [PATCH 029/118] remove dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/catalog-client/src/types/api.ts | 5 ++-- .../src/service/DefaultEntitiesCatalog.ts | 25 ------------------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index a99f36573e..64d01c0972 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -423,7 +423,7 @@ export type QueryEntitiesRequest = * * @remarks * - * Either `filter` or `query` can be provided, but not both: + * Either `filter` or `query` can be provided, or even both: * - `filter`: Uses the traditional key-value filter syntax (GET endpoint) * - `query`: Uses the predicate-based filter syntax with logical operators (POST endpoint) * @@ -434,12 +434,11 @@ export type QueryEntitiesInitialRequest = { limit?: number; offset?: number; /** - * Traditional key-value based filter. Mutually exclusive with `query`. + * Traditional key-value based filter. */ filter?: EntityFilterQuery; /** * Predicate-based filter with logical operators ($all, $any, $not, $exists, $in, $hasPrefix). - * Mutually exclusive with `filter`. * * @example * ```typescript diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 11bce7bd86..dab605d176 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -18,7 +18,6 @@ import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { InputError, NotFoundError } from '@backstage/errors'; import { Knex } from 'knex'; import { chunk as lodashChunk, isEqual } from 'lodash'; -import { z } from 'zod'; import { Cursor, EntitiesBatchRequest, @@ -49,7 +48,6 @@ import { isQueryEntitiesCursorRequest, isQueryEntitiesInitialRequest, } from './util'; -import { EntityFilter } from '@backstage/plugin-catalog-node'; import { LoggerService } from '@backstage/backend-plugin-api'; import { applyEntityFilterToQuery } from './request/applyEntityFilterToQuery'; import { processRawEntitiesResult } from './response'; @@ -714,29 +712,6 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { } } -const entityFilterParser: z.ZodSchema = z.lazy(() => - z - .object({ - key: z.string(), - values: z.array(z.string()).optional(), - }) - .or(z.object({ not: entityFilterParser })) - .or(z.object({ anyOf: z.array(entityFilterParser) })) - .or(z.object({ allOf: z.array(entityFilterParser) })), -); - -export const cursorParser: z.ZodSchema = z.object({ - orderFields: z.array( - z.object({ field: z.string(), order: z.enum(['asc', 'desc']) }), - ), - orderFieldValues: z.array(z.string().or(z.null())), - filter: entityFilterParser.optional(), - isPrevious: z.boolean(), - query: z.string().optional(), - firstSortFieldValues: z.array(z.string().or(z.null())).optional(), - totalItems: z.number().optional(), -}); - function parseCursorFromRequest( request?: QueryEntitiesRequest, ): Partial & { skipTotalItems: boolean } { From a3e4add213840810696dca100a60dc61020b9082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 15:01:38 +0100 Subject: [PATCH 030/118] remove some unnecessary noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../applyPredicateEntityFilterToQuery.ts | 45 +------------------ 1 file changed, 2 insertions(+), 43 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index b42252369d..d376b132ff 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -35,45 +35,6 @@ function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -/** - * example generated query - * Selects all non-null final entities that: - * - * - Are of kind "component" - * - Have spec.type = "service" - * - Are owned by either: - * - backend-team - * - platform-team - * - Are NOT in experimental lifecycle - * - * Results are ordered by entity_ref in ascending order. - * - * SQL Reference: - * ``` - * SELECT final_entities.* - * FROM final_entities - * WHERE final_entities.final_entity IS NOT NULL - * AND final_entities.entity_id IN ( - * SELECT entity_id FROM search WHERE key = 'kind' AND value = 'component' - * ) - * AND final_entities.entity_id IN ( - * SELECT entity_id FROM search WHERE key = 'spec.type' AND value = 'service' - * ) - * AND ( - * final_entities.entity_id IN ( - * SELECT entity_id FROM search WHERE key = 'spec.owner' AND value = 'backend-team' - * ) - * OR - * final_entities.entity_id IN ( - * SELECT entity_id FROM search WHERE key = 'spec.owner' AND value = 'platform-team' - * ) - * ) - * AND final_entities.entity_id NOT IN ( - * SELECT entity_id FROM search WHERE key = 'spec.lifecycle' AND value = 'experimental' - * ) - * ORDER BY final_entities.entity_ref ASC; - * ``` - */ export function applyPredicateEntityFilterToQuery(options: { filter: FilterPredicate; targetQuery: Knex.QueryBuilder; @@ -190,9 +151,7 @@ function applyFieldCondition(options: { } if ('$in' in value) { - const values = (value.$in as FilterPredicatePrimitive[]).map(v => - String(v).toLocaleLowerCase('en-US'), - ); + const values = value.$in.map(v => String(v).toLocaleLowerCase('en-US')); const matchQuery = knex('search') .select('search.entity_id') .where({ key }) @@ -201,7 +160,7 @@ function applyFieldCondition(options: { } if ('$hasPrefix' in value) { - const prefix = (value.$hasPrefix as string).toLocaleLowerCase('en-US'); + const prefix = value.$hasPrefix.toLocaleLowerCase('en-US'); const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`); const matchQuery = knex('search') .select('search.entity_id') From c7c0dd5ed2688c2e19567054f5f31ecd4f48f905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 15:11:59 +0100 Subject: [PATCH 031/118] add test for empty matcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../request/applyPredicateEntityFilterToQuery.test.ts | 4 ++++ .../request/applyPredicateEntityFilterToQuery.ts | 11 ++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index b90e0ca409..e48be8169a 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -126,6 +126,10 @@ describe.each(databases.eachSupportedId())( ]); }); + it('matches nothing for {$not: {}}', async () => { + await expect(query({ $not: {} })).resolves.toEqual([]); + }); + it('filters by direct field value', async () => { await expect(query({ kind: 'component' })).resolves.toEqual([ 'bare-e', diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index d376b132ff..e7cf152184 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -102,7 +102,7 @@ export function applyPredicateEntityFilterToQuery(options: { // Treat the filter as a field expression like { "kind": "component" } or { "spec.type": { "$in": ["service", "website"] } } if (Object.keys(filter).length === 0) { - return targetQuery; + return targetQuery.andWhereRaw('1 = 1'); } return targetQuery.andWhere(inner => { for (const [keyAnyCase, value] of Object.entries(filter)) { @@ -144,10 +144,11 @@ function applyFieldCondition(options: { const existsQuery = knex('search') .select('search.entity_id') .where({ key }); - if (value.$exists) { - return targetQuery.andWhere(onEntityIdField, 'in', existsQuery); - } - return targetQuery.andWhere(onEntityIdField, 'not in', existsQuery); + return targetQuery.andWhere( + onEntityIdField, + value.$exists ? 'in' : 'not in', + existsQuery, + ); } if ('$in' in value) { From c25c589f9cd8733c771efe5c56e9d0732a513833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 15:29:39 +0100 Subject: [PATCH 032/118] add primitive $contains support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../applyPredicateEntityFilterToQuery.test.ts | 328 ++++++++++-------- .../applyPredicateEntityFilterToQuery.ts | 41 ++- 2 files changed, 215 insertions(+), 154 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index e48be8169a..7f07cad11e 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -67,7 +67,11 @@ describe.each(databases.eachSupportedId())( await addEntity({ apiVersion: 'backstage.io/v1alpha1', kind: 'Component', - metadata: { name: 'bare-e', namespace: 'default' }, + metadata: { + name: 'bare-e', + namespace: 'default', + tags: ['java', 'backend'], + }, spec: {}, }); }); @@ -116,151 +120,185 @@ describe.each(databases.eachSupportedId())( ); } - it('matches everything for empty field expression', async () => { - await expect(query({})).resolves.toEqual([ - 'api-d', - 'bare-e', - 'service-a', - 'service-b', - 'website-c', - ]); - }); - - it('matches nothing for {$not: {}}', async () => { - await expect(query({ $not: {} })).resolves.toEqual([]); - }); - - it('filters by direct field value', async () => { - await expect(query({ kind: 'component' })).resolves.toEqual([ - 'bare-e', - 'service-a', - 'service-b', - 'website-c', - ]); - }); - - it('filters by exact spec field value', async () => { - await expect(query({ 'spec.type': 'service' })).resolves.toEqual([ - 'service-a', - 'service-b', - ]); - }); - - it('filters with $all', async () => { - await expect( - query({ - $all: [{ kind: 'component' }, { 'spec.type': 'service' }], - }), - ).resolves.toEqual(['service-a', 'service-b']); - }); - - it('matches everything for empty $all', async () => { - await expect(query({ $all: [] })).resolves.toEqual([ - 'api-d', - 'bare-e', - 'service-a', - 'service-b', - 'website-c', - ]); - }); - - it('filters with $any', async () => { - await expect( - query({ - $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], - }), - ).resolves.toEqual(['service-a', 'service-b', 'website-c']); - }); - - it('returns nothing for empty $any', async () => { - await expect(query({ $any: [] })).resolves.toEqual([]); - }); - - it('filters with $not', async () => { - await expect( - query({ - $all: [ - { kind: 'component' }, - { $not: { 'spec.lifecycle': 'experimental' } }, - ], - }), - ).resolves.toEqual(['bare-e', 'service-a', 'website-c']); - }); - - it('filters with $in', async () => { - await expect( - query({ 'spec.type': { $in: ['service', 'openapi'] } }), - ).resolves.toEqual(['api-d', 'service-a', 'service-b']); - }); - - it('filters with $exists true', async () => { - await expect( - query({ - $all: [{ kind: 'component' }, { 'spec.owner': { $exists: true } }], - }), - ).resolves.toEqual(['service-a', 'service-b', 'website-c']); - }); - - it('filters with $exists false', async () => { - await expect( - query({ - $all: [{ kind: 'component' }, { 'spec.owner': { $exists: false } }], - }), - ).resolves.toEqual(['bare-e']); - }); - - it('filters with $hasPrefix', async () => { - await expect( - query({ 'metadata.name': { $hasPrefix: 'service' } }), - ).resolves.toEqual(['service-a', 'service-b']); - }); - - it('filters with $hasPrefix case-insensitively', async () => { - await expect( - query({ 'metadata.name': { $hasPrefix: 'Service' } }), - ).resolves.toEqual(['service-a', 'service-b']); - }); - - it('handles nested logical operators', async () => { - await expect( - query({ - $all: [ - { kind: 'component' }, - { - $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], - }, - { $not: { 'spec.lifecycle': 'experimental' } }, - ], - }), - ).resolves.toEqual(['service-a', 'website-c']); - }); - - it('throws on $contains operator', async () => { - await expect( - query({ 'metadata.name': { $contains: 'service' } as any }), - ).rejects.toThrow(/\$contains operator is not supported/); - }); - - it('throws on top-level primitive', async () => { - await expect(query('bad-value' as any)).rejects.toThrow( - /top-level primitive values are not supported/, - ); - }); - - it('combines filter and query independently', async () => { - const q = - knex('final_entities').whereNotNull('final_entity'); - applyEntityFilterToQuery({ - filter: { key: 'kind', values: ['component'] }, - query: { 'spec.type': 'service' }, - targetQuery: q, - onEntityIdField: 'final_entities.entity_id', - knex, + describe('field expressions', () => { + it('matches everything for empty field expression', async () => { + await expect(query({})).resolves.toEqual([ + 'api-d', + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + + it('filters by direct field value', async () => { + await expect(query({ kind: 'component' })).resolves.toEqual([ + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + + it('filters by exact spec field value', async () => { + await expect(query({ 'spec.type': 'service' })).resolves.toEqual([ + 'service-a', + 'service-b', + ]); + }); + + it('throws on top-level primitive', async () => { + await expect(query('bad-value' as any)).rejects.toThrow( + /top-level primitive values are not supported/, + ); + }); + }); + + describe('$all', () => { + it('filters with $all', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.type': 'service' }], + }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + + it('matches everything for empty $all', async () => { + await expect(query({ $all: [] })).resolves.toEqual([ + 'api-d', + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + }); + + describe('$any', () => { + it('filters with $any', async () => { + await expect( + query({ + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('returns nothing for empty $any', async () => { + await expect(query({ $any: [] })).resolves.toEqual([]); + }); + }); + + describe('$not', () => { + it('filters with $not', async () => { + await expect( + query({ + $all: [ + { kind: 'component' }, + { $not: { 'spec.lifecycle': 'experimental' } }, + ], + }), + ).resolves.toEqual(['bare-e', 'service-a', 'website-c']); + }); + + it('matches nothing for {$not: {}}', async () => { + await expect(query({ $not: {} })).resolves.toEqual([]); + }); + }); + + describe('$in', () => { + it('filters with $in', async () => { + await expect( + query({ 'spec.type': { $in: ['service', 'openapi'] } }), + ).resolves.toEqual(['api-d', 'service-a', 'service-b']); + }); + }); + + describe('$exists', () => { + it('filters with $exists true', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.owner': { $exists: true } }], + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('filters with $exists false', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.owner': { $exists: false } }], + }), + ).resolves.toEqual(['bare-e']); + }); + }); + + describe('$hasPrefix', () => { + it('filters with $hasPrefix', async () => { + await expect( + query({ 'metadata.name': { $hasPrefix: 'service' } }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + + it('filters with $hasPrefix case-insensitively', async () => { + await expect( + query({ 'metadata.name': { $hasPrefix: 'Service' } }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + }); + + describe('$contains', () => { + it('filters with primitive $contains on array fields', async () => { + await expect( + query({ 'metadata.tags': { $contains: 'java' } }), + ).resolves.toEqual(['bare-e']); + }); + + it('throws on non-primitive $contains', async () => { + await expect( + query({ + 'metadata.tags': { $contains: { nested: 'object' } } as any, + }), + ).rejects.toThrow( + /Non primitive forms of the \$contains operator is not supported/, + ); + }); + }); + + describe('nested operators', () => { + it('handles nested logical operators', async () => { + await expect( + query({ + $all: [ + { kind: 'component' }, + { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + { $not: { 'spec.lifecycle': 'experimental' } }, + ], + }), + ).resolves.toEqual(['service-a', 'website-c']); + }); + }); + + describe('combined filter and query', () => { + it('combines filter and query independently', async () => { + const q = + knex('final_entities').whereNotNull( + 'final_entity', + ); + applyEntityFilterToQuery({ + filter: { key: 'kind', values: ['component'] }, + query: { 'spec.type': 'service' }, + targetQuery: q, + onEntityIdField: 'final_entities.entity_id', + knex, + }); + const result = await q.then(rows => + rows + .map(row => JSON.parse(row.final_entity!).metadata.name) + .toSorted(), + ); + expect(result).toEqual(['service-a', 'service-b']); }); - const result = await q.then(rows => - rows.map(row => JSON.parse(row.final_entity!).metadata.name).toSorted(), - ); - expect(result).toEqual(['service-a', 'service-b']); }); }, ); diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index e7cf152184..27701a90ef 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -45,11 +45,9 @@ export function applyPredicateEntityFilterToQuery(options: { // We do not support top-level primitives; all matching happens through objects if (!isObject(filter)) { + const actual = JSON.stringify(filter); throw new InputError( - `Invalid filter predicate: top-level primitive values are not supported. ` + - `Wrap the value in a field expression, e.g. { "kind": ${JSON.stringify( - filter, - )} }`, + `Invalid filter predicate: top-level primitive values are not supported. Wrap the value in a field expression, e.g. { "kind": ${actual} }`, ); } @@ -171,14 +169,39 @@ function applyFieldCondition(options: { } if ('$contains' in value) { - // TODO(freben): Implement this, AT LEAST for some special cases such as tags and relations. - throw new InputError('The $contains operator is not supported'); + const target = value.$contains; + + // If the target is a primitive, match on the special array syntax. + // + // FROM: `{ "a": { "$contains": "b" } }` + // + // TO: `{ "a.b": "true" }` + // + // The search table does not actually show us that "a" was an array to + // begin with, so this can mistakenly also match on an object that had a + // "b" key with a true value. We'll consider that an acceptable tradeoff + // though. + if (isPrimitive(target)) { + const matchQuery = knex('search') + .select('search.entity_id') + .where({ + key: `${key}.${String(target).toLocaleLowerCase('en-US')}`, + value: 'true', + }); + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); + } + + // TODO(freben): Implement this for more use cases - for example simple + // objects (resulting in a $and and appending key strings) and maybe + // a subset of relations? + throw new InputError( + 'Non primitive forms of the $contains operator is not supported', + ); } } + const actual = JSON.stringify(value); throw new InputError( - `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${JSON.stringify( - value, - )}`, + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${actual}`, ); } From bec9fe21d7bd2d070ba07b870600e94c7d1a024a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 17:04:59 +0100 Subject: [PATCH 033/118] implement relations contains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../applyPredicateEntityFilterToQuery.test.ts | 132 +++++++++++++++++- .../applyPredicateEntityFilterToQuery.ts | 132 +++++++++++++++++- 2 files changed, 256 insertions(+), 8 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index 7f07cad11e..cea9d961f7 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -45,18 +45,24 @@ describe.each(databases.eachSupportedId())( kind: 'Component', metadata: { name: 'service-a', namespace: 'default' }, spec: { type: 'service', lifecycle: 'production', owner: 'team-a' }, + relations: [ + { type: 'ownedBy', targetRef: 'group:default/team-a' }, + { type: 'consumesApi', targetRef: 'api:default/api-d' }, + ], }); await addEntity({ apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: 'service-b', namespace: 'default' }, spec: { type: 'service', lifecycle: 'experimental', owner: 'team-b' }, + relations: [{ type: 'ownedBy', targetRef: 'group:default/team-b' }], }); await addEntity({ apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: 'website-c', namespace: 'default' }, spec: { type: 'website', lifecycle: 'production', owner: 'team-a' }, + relations: [{ type: 'ownedBy', targetRef: 'group:default/team-a' }], }); await addEntity({ apiVersion: 'backstage.io/v1alpha1', @@ -147,6 +153,12 @@ describe.each(databases.eachSupportedId())( ]); }); + it('throws on unsupported field operator', async () => { + await expect(query({ kind: { $bad: 'value' } as any })).rejects.toThrow( + /\$contains operator, but got/, + ); + }); + it('throws on top-level primitive', async () => { await expect(query('bad-value' as any)).rejects.toThrow( /top-level primitive values are not supported/, @@ -252,13 +264,127 @@ describe.each(databases.eachSupportedId())( ).resolves.toEqual(['bare-e']); }); - it('throws on non-primitive $contains', async () => { + it('matches relations by type only (existence)', async () => { + await expect( + query({ relations: { $contains: { type: 'consumesApi' } } }), + ).resolves.toEqual(['service-a']); + }); + + it('matches relations by type and exact targetRef', async () => { await expect( query({ - 'metadata.tags': { $contains: { nested: 'object' } } as any, + relations: { + $contains: { + type: 'ownedBy', + targetRef: 'group:default/team-a', + }, + }, + }), + ).resolves.toEqual(['service-a', 'website-c']); + }); + + it('matches relations by type and targetRef case-insensitively', async () => { + await expect( + query({ + relations: { + $contains: { + type: 'OwnedBy', + targetRef: 'Group:Default/Team-A', + }, + }, + }), + ).resolves.toEqual(['service-a', 'website-c']); + }); + + it('handles mixed-case keys in $contains object', async () => { + await expect( + query({ + relations: { + $contains: { + TyPe: 'ownedBy', + TargetRef: 'group:default/team-a', + }, + }, + } as any), + ).resolves.toEqual(['service-a', 'website-c']); + }); + + it('matches relations by type and targetRef with $in', async () => { + await expect( + query({ + relations: { + $contains: { + type: 'ownedBy', + targetRef: { + $in: ['group:default/team-a', 'group:default/team-b'], + }, + }, + }, + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('throws on unsupported keys in $contains object', async () => { + await expect( + query({ + relations: { + $contains: { type: 'ownedBy', badKey: 'value' }, + } as any, + }), + ).rejects.toThrow(/Unsupported key "badKey" in \$contains/); + }); + + it('throws on duplicate keys in $contains object', async () => { + await expect( + query({ + relations: { + // JSON.parse allows duplicate keys, last one wins, but we + // simulate via an object that has both casings + $contains: JSON.parse('{"type": "ownedBy", "Type": "ownedBy"}'), + }, + }), + ).rejects.toThrow(/Duplicate key "Type" in \$contains/); + }); + + it('throws on $contains object without type', async () => { + await expect( + query({ + relations: { + $contains: { targetRef: 'group:default/team-a' }, + } as any, + }), + ).rejects.toThrow(/requires a "type" string property/); + }); + + it('throws on empty $in array in targetRef', async () => { + await expect( + query({ + relations: { + $contains: { type: 'ownedBy', targetRef: { $in: [] } }, + }, + }), + ).rejects.toThrow(/Empty "\$in" array for \$contains on "relations"/); + }); + + it('throws on unsupported targetRef value', async () => { + await expect( + query({ + relations: { + $contains: { type: 'ownedBy', targetRef: ['bad'] }, + } as any, + }), + ).rejects.toThrow(/Unsupported value in \$contains for "relations"/); + }); + + it('throws on object $contains for non-relations fields', async () => { + await expect( + query({ + 'metadata.tags': { + $contains: { type: 'something' }, + } as any, }), ).rejects.toThrow( - /Non primitive forms of the \$contains operator is not supported/, + /Object form of \$contains is not supported for field/, ); }); }); diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 27701a90ef..a44f878abd 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -191,17 +191,139 @@ function applyFieldCondition(options: { return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); } - // TODO(freben): Implement this for more use cases - for example simple - // objects (resulting in a $and and appending key strings) and maybe - // a subset of relations? + // Object form of $contains - currently only supports relation-style + // objects with "type" and optional "targetRef" keys. + // + // FROM: `{ "relations": { "$contains": { "type": "ownedBy", "targetRef": "group:default/team-a" } } }` + // + // TO: search for key = "relations.ownedby" AND value = "group:default/team-a" + if (isObject(target)) { + if (key === 'relations') { + return applyContainsRelation({ + target, + targetQuery, + onEntityIdField, + knex, + }); + } + + throw new InputError( + `Object form of $contains is not supported for field "${key}"`, + ); + } + + const actual = JSON.stringify(target); throw new InputError( - 'Non primitive forms of the $contains operator is not supported', + `Unsupported $contains target for field "${key}": ${actual}`, ); } } const actual = JSON.stringify(value); throw new InputError( - `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${actual}`, + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, $hasPrefix, or $contains operator, but got ${actual}`, ); } + +/** + * Handles expressions on the form + * + * ``` + * { + * "relations": { + * "$contains": { + * "type": "ownedBy", + * "targetRef": "group:default/team-a" + * } + * } + * } + * ``` + * + * which map onto the search table's special `relation.: ` + * syntax. + * + * Only the keys "type" and "targetRef" are supported. The "type" key is + * required. If "targetRef" is omitted, it becomes an existence check for any + * relation of that type. The "targetRef" value can be a string or an `$in` + * array. + */ +function applyContainsRelation(options: { + target: Record; + targetQuery: Knex.QueryBuilder; + onEntityIdField: string; + knex: Knex; +}): Knex.QueryBuilder { + const { target: rawTarget, targetQuery, onEntityIdField, knex } = options; + + function parseStringOrIn(value: unknown): string[] { + if (typeof value === 'string') { + return [value.toLocaleLowerCase('en-US')]; + } + if ( + isObject(value) && + Object.keys(value).length === 1 && + '$in' in value && + Array.isArray(value.$in) && + value.$in.every((v): v is string => typeof v === 'string') + ) { + if (value.$in.length === 0) { + throw new InputError( + `Empty "$in" array for $contains on "relations" is not allowed`, + ); + } + return value.$in.map(v => v.toLocaleLowerCase('en-US')); + } + const actual = JSON.stringify(value); + throw new InputError( + `Unsupported value in $contains for "relations": expected a string or { "$in": [strings] }, but got ${actual}`, + ); + } + + let type: string | undefined; + let targetRef: string[] | undefined; + + for (const [rawKey, value] of Object.entries(rawTarget)) { + const key = rawKey.toLocaleLowerCase('en-US'); + + if (key === 'type') { + if (type !== undefined) { + throw new InputError( + `Duplicate key "${rawKey}" in $contains for "relations"`, + ); + } + if (typeof value !== 'string') { + throw new InputError( + `The $contains operator for "relations" requires a "type" string property`, + ); + } + type = value; + } else if (key === 'targetref') { + if (targetRef !== undefined) { + throw new InputError( + `Duplicate key "${rawKey}" in $contains for "relations"`, + ); + } + targetRef = parseStringOrIn(value); + } else { + throw new InputError( + `Unsupported key "${rawKey}" in $contains for "relations". Only "type" and "targetRef" are supported`, + ); + } + } + + if (!type) { + throw new InputError( + `The $contains operator for "relations" requires a "type" string property`, + ); + } + + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key: `relations.${type.toLocaleLowerCase('en-US')}` }); + + if (targetRef) { + matchQuery.whereIn('value', targetRef); + } + + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); +} From c4aa99e8d5a7d31ee312e426ff9c9b74379a94b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 21 Feb 2026 14:34:56 +0100 Subject: [PATCH 034/118] clarify changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/rich-ducks-ring.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/rich-ducks-ring.md b/.changeset/rich-ducks-ring.md index ea8a3e8016..5f046e31b2 100644 --- a/.changeset/rich-ducks-ring.md +++ b/.changeset/rich-ducks-ring.md @@ -5,6 +5,6 @@ Added predicate-based entity filtering via POST /entities/by-query endpoint. -Supports `$all`, `$any`, `$not`, `$exists`, `$in`, and `$hasPrefix` operators for expressive entity queries. Integrated into the existing `queryEntities` flow with full cursor-based pagination, permission enforcement, and `totalItems` support. +Supports `$all`, `$any`, `$not`, `$exists`, `$in`, `$hasPrefix`, and (partially) `$contains` operators for expressive entity queries. Integrated into the existing `queryEntities` flow with full cursor-based pagination, permission enforcement, and `totalItems` support. The catalog client's `queryEntities()` method automatically routes to the POST endpoint when a `query` predicate is provided. From 4a47c603e1ec2eedbe26821be292a30f41e66c21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 21 Feb 2026 16:10:40 +0100 Subject: [PATCH 035/118] add docs too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- docs/features/software-catalog/api.md | 187 +++++++++++++++++++++++ packages/catalog-client/report.api.md | 2 +- packages/catalog-client/src/types/api.ts | 10 +- 3 files changed, 194 insertions(+), 5 deletions(-) diff --git a/docs/features/software-catalog/api.md b/docs/features/software-catalog/api.md index ca8b8f638d..3cd70538c2 100644 --- a/docs/features/software-catalog/api.md +++ b/docs/features/software-catalog/api.md @@ -210,6 +210,193 @@ if `prevCursor` exists, it can be used to retrieve the previous batch of entitie it isn't possible to change any of [`filter`, `orderField`, `fullTextFilter`] when passing `cursor` as query parameters, as changing any of these properties will affect pagination. If any of `filter`, `orderField`, `fullTextFilter` is specified together with `cursor`, only the latter is taken into consideration. +### `POST /entities/by-query` + +This supports the same features as the `GET` variant, but in a `POST` body to +not have to abide by URL length limits. Additionally, it supports advanced, more +expressive querying format - see below. The response format is identical. + +#### Querying by filter predicate + +You can pass in a filter predicate to select a subset of entities in the +catalog. They are comprised of an optional logical expression tree (using +`$all`, `$any`, `$not`), ending in filter sets that can have custom matchers +(e.g. `$exists`, `$in`, `$hasPrefix`, `$contains`). + +This is an example of what such a filter predicate expression might look like: + +```js +{ + "query": { + "$all": [ + { + "kind": "Component", + "spec.type": { "$in": ["service", "website"] } + }, + { + "$not": { + "metadata.annotations.backstage.io/orphan": "true" + } + } + ] + } +} +``` + +A filter set is an object whose keys are dot separated paths into an object, and +the values are either primitives (string, number, or boolean) or custom matchers +as per below. An example of a simple such filter set is: + +```js +// All of the following must be true for a given entity (there's an +// implicit AND between them) +{ + // The kind field is matched against a literal, case insensitively + "kind": "Component", + // The type field inside the spec is matched using a custom matcher, see below + "spec.type": { "$in": ["service", "website"] } +} +``` + +The root of the query is always an object, whether there is a logic expression +tree or not. Nodes with a single key that starts with a `$` sign have special +meaning. + +- `$not`: Logical negation. + + Its value must be a single expression. Example: + + ```js + // Matches entities that do NOT have kind Component + { + "$not": { + "kind": "Component", + } + } + ``` + + Note that `$not` cannot be used in a right hand side value matcher. + + ```js + // ❌ WRONG + { "kind": { "$not": "Component" } } + // ✅ CORRECT + { "$not": { "kind": "Component" } } + ``` + +- `$all`: Require that all given expressions match each entity. + + Its value must be an array of expressions. Example: + + ```js + // Matches entities that BOTH have kind Component and type website + { + "$all": [ + { "kind": "Component" }, + { "spec.type": "website" } + ] + } + ``` + + An empty array always matches every entity. + +- `$any`: Require that at least one of a set of expressions match a given entity. + + Its value must be an array of expressions. Example: + + ```js + // Matches entities that EITHER have kind Component or type website + { + "$any": [ + { "kind": "Component" }, + { "spec.type": "website" } + ] + } + ``` + + An empty array never matches anything. + +- `$exists`: Assert on the existence of fields. + + Its value is either `true`, meaning that the field must exist on the entity + (no matter what its value), or `false`, meaning that it must not exist. + Example: + + ```js + // Matches entities that DO NOT have that annotation, ignoring what the + // value might be + { + "metadata.annotations.backstage.io/orphan": { + "$exists": false + }, + } + ``` + +- `$in`: Assert that a field has any of a set of primitive values. + + Its value must be an array of string, number, and/or boolean values. Example: + + ```js + // Matches entities whose type is EITHER service or website + { + "spec.type": { + "$in": ["service", "website"] + } + } + ``` + + The matching is case insensitive. An empty array never matches anything. + +- `$hasPrefix`: Assert that a field is a string that starts with a certain prefix text. + + Its value is a string. Example: + + ```js + // Matches entities whose project slug annotation starts with "backstage/" + { + "metadata.annotations.github.com/project-slug": { + "$hasPrefix": "backstage/" + } + } + ``` + + The matching is case insensitive, and captures both exact matches and strings + that start with the given prefix. + +- `$contains`: Assert that an array contains an element that matches the given expression. + + There is only limited support for this matcher. One use case is for relations: + + ```js + { + // Specifically type and (optionally) targetRef supported, and only + // with equality or "$in" for the targetRef + "relations": { + "$contains": { + "type": "ownedBy", + "targetRef": { + "$in": ["user:default/foo", "group:default/bar"] + } + } + } + } + ``` + + The other use case is for arrays where you match with a primitive value, such + as labels. Example: + + ```js + { + // Works for any field, as long as the value is a primitive + // (either string, number, or boolean) + "metadata.labels": { + "$contains": "java" + } + } + ``` + + In every case, the matching is case insensitive. + ### `GET /entities` Lists entities. diff --git a/packages/catalog-client/report.api.md b/packages/catalog-client/report.api.md index cc83cce443..0d71e6bc59 100644 --- a/packages/catalog-client/report.api.md +++ b/packages/catalog-client/report.api.md @@ -8,7 +8,7 @@ import type { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; import type { FilterPredicate } from '@backstage/filter-predicates'; -import { SerializedError } from '@backstage/errors'; +import type { SerializedError } from '@backstage/errors'; // @public export type AddLocationRequest = { diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 64d01c0972..c39c0c872e 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { CompoundEntityRef, Entity } from '@backstage/catalog-model'; -import { SerializedError } from '@backstage/errors'; +import type { CompoundEntityRef, Entity } from '@backstage/catalog-model'; +import type { SerializedError } from '@backstage/errors'; import type { AnalyzeLocationRequest, AnalyzeLocationResponse, @@ -438,7 +438,9 @@ export type QueryEntitiesInitialRequest = { */ filter?: EntityFilterQuery; /** - * Predicate-based filter with logical operators ($all, $any, $not, $exists, $in, $hasPrefix). + * Predicate-based filter with operators for logical expressions (`$all`, + * `$any`, and `$not`) and matching (`$exists`, `$in`, `$hasPrefix`, and + * (partially) `$contains`). * * @example * ```typescript @@ -446,7 +448,7 @@ export type QueryEntitiesInitialRequest = { * query: { * $all: [ * { kind: 'component' }, - * { 'spec.type': 'service' } + * { 'spec.type': { $in: ['service', 'website'] } } * ] * } * } From 1464d88200bec8658a23d5b694b5a118dc314a79 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:51:37 +0000 Subject: [PATCH 036/118] chore(deps): update dependency testcontainers to v11.12.0 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 61 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/yarn.lock b/yarn.lock index d1dbdea01c..8048529680 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10921,6 +10921,15 @@ __metadata: languageName: node linkType: hard +"@kwsites/file-exists@npm:^1.1.1": + version: 1.1.1 + resolution: "@kwsites/file-exists@npm:1.1.1" + dependencies: + debug: "npm:^4.1.1" + checksum: 10/4ff945de7293285133aeae759caddc71e73c4a44a12fac710fdd4f574cce2671a3f89d8165fdb03d383cfc97f3f96f677d8de3c95133da3d0e12a123a23109fe + languageName: node + linkType: hard + "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.3 resolution: "@leichtgewicht/ip-codec@npm:2.0.3" @@ -21434,14 +21443,14 @@ __metadata: languageName: node linkType: hard -"@types/dockerode@npm:^3.3.47": - version: 3.3.47 - resolution: "@types/dockerode@npm:3.3.47" +"@types/dockerode@npm:^4.0.1": + version: 4.0.1 + resolution: "@types/dockerode@npm:4.0.1" dependencies: "@types/docker-modem": "npm:*" "@types/node": "npm:*" "@types/ssh2": "npm:*" - checksum: 10/b840ae7872398a3b02e5789006a69d0cf5bb7ec6c0eb714c7ca04ca093add8de4cd06204ecd8f01388e347e62927cf4c599e8b7dba53e81c1350910da766d517 + checksum: 10/d16b3a69a20fac269b2317a978442a6752dea158729a264511a3812dcb4c756e0ee079b39b61068cee182f268661e27e32aa7a28815262f0e088ffeb9f2f48c5 languageName: node linkType: hard @@ -29538,12 +29547,12 @@ __metadata: languageName: node linkType: hard -"docker-compose@npm:^1.3.0": - version: 1.3.0 - resolution: "docker-compose@npm:1.3.0" +"docker-compose@npm:^1.3.1": + version: 1.3.1 + resolution: "docker-compose@npm:1.3.1" dependencies: yaml: "npm:^2.2.2" - checksum: 10/7c1b395fc104031eadef08cac0c028af11fd9e3084265dca9e17bc76ad27c2bfe96d8b74df258bb64b198f9e0f1792a7f2c63123cddc0d60bb776f211d54cfb2 + checksum: 10/39e0e4b96c0dea8c88df9770a4d102a9e2437ad0af5f5f28a9f88fbf164eb4fb81fde0d8740500bd451abafab74072e5d5cfa35cc85254722bbe2b7dbc7c033a languageName: node linkType: hard @@ -39640,6 +39649,15 @@ __metadata: languageName: node linkType: hard +"mkdirp@npm:^3.0.1": + version: 3.0.1 + resolution: "mkdirp@npm:3.0.1" + bin: + mkdirp: dist/cjs/src/bin.js + checksum: 10/16fd79c28645759505914561e249b9a1f5fe3362279ad95487a4501e4467abeb714fd35b95307326b8fd03f3c7719065ef11a6f97b7285d7888306d1bd2232ba + languageName: node + linkType: hard + "mock-socket@npm:^9.3.0": version: 9.3.1 resolution: "mock-socket@npm:9.3.1" @@ -43410,12 +43428,13 @@ __metadata: languageName: node linkType: hard -"properties-reader@npm:^2.3.0": - version: 2.3.0 - resolution: "properties-reader@npm:2.3.0" +"properties-reader@npm:^3.0.1": + version: 3.0.1 + resolution: "properties-reader@npm:3.0.1" dependencies: - mkdirp: "npm:^1.0.4" - checksum: 10/0b41eb4136dc278ae0d97968ccce8de2d48d321655b319192e31f2424f1c6e052182204671e65aa8967216360cb3e7cbd9129830062e058fe9d6a1d74964c29a + "@kwsites/file-exists": "npm:^1.1.1" + mkdirp: "npm:^3.0.1" + checksum: 10/5a96c33dad9925c399bf3c3e343f3eb801fa1b4802330d1bfbf1e7d7639dafa935deb6c60be38b292544b57793b03ba1eaf5191cc3a7d018cda7bd6ead19a15c languageName: node linkType: hard @@ -48434,25 +48453,25 @@ __metadata: linkType: hard "testcontainers@npm:^11.9.0": - version: 11.11.0 - resolution: "testcontainers@npm:11.11.0" + version: 11.12.0 + resolution: "testcontainers@npm:11.12.0" dependencies: "@balena/dockerignore": "npm:^1.0.2" - "@types/dockerode": "npm:^3.3.47" + "@types/dockerode": "npm:^4.0.1" archiver: "npm:^7.0.1" async-lock: "npm:^1.4.1" byline: "npm:^5.0.0" debug: "npm:^4.4.3" - docker-compose: "npm:^1.3.0" + docker-compose: "npm:^1.3.1" dockerode: "npm:^4.0.9" get-port: "npm:^7.1.0" proper-lockfile: "npm:^4.1.2" - properties-reader: "npm:^2.3.0" + properties-reader: "npm:^3.0.1" ssh-remote-port-forward: "npm:^1.0.4" tar-fs: "npm:^3.1.1" tmp: "npm:^0.2.5" - undici: "npm:^7.16.0" - checksum: 10/6a05baf5cd9ff0eab7ecb6c31f10e1204d9b4a88698f2ecea9f4008a6ca9130302a83f0b79e303bfda577de3dd26c74689bb4636720b28d7dd3753ce50422865 + undici: "npm:^7.22.0" + checksum: 10/d15ebc0be1c09192c4c26060bfec2f7ad2cf338624bed4a1ca2d548419853e54ea7deb8cd21cca21d55976552f09a00093ab7be822017e045f4c29e09b7e9ba2 languageName: node linkType: hard @@ -49619,7 +49638,7 @@ __metadata: languageName: node linkType: hard -"undici@npm:^7.16.0, undici@npm:^7.2.3": +"undici@npm:^7.2.3, undici@npm:^7.22.0": version: 7.22.0 resolution: "undici@npm:7.22.0" checksum: 10/a7a1813ba4b74c0d46cc8dd160386202c05699ffc487c5d882cf40e6d2435c8d6faff3b8f8675d09bd1ef0386e370675c26b59b9a8c8b3f17b9f82a42236a927 From df3913ec1998e5789fd6de236bbf32f3c97faecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ahlert=20Junior?= Date: Mon, 23 Feb 2026 04:13:37 -0300 Subject: [PATCH 037/118] Add n8n to plugin directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Ahlert Junior --- microsite/data/plugins/n8n.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 microsite/data/plugins/n8n.yaml diff --git a/microsite/data/plugins/n8n.yaml b/microsite/data/plugins/n8n.yaml new file mode 100644 index 0000000000..7898c1efe4 --- /dev/null +++ b/microsite/data/plugins/n8n.yaml @@ -0,0 +1,11 @@ +--- +title: n8n +author: André Ahlert Junior +authorUrl: https://github.com/andreahlert +category: Other +description: n8n workflow automation — view workflows and execution history on Backstage entity pages. +documentation: https://github.com/backstage/community-plugins/tree/main/workspaces/n8n/plugins/n8n +iconUrl: /img/logo-gradient-on-dark.svg +npmPackageName: '@backstage-community/plugin-n8n' +addedDate: '2026-02-15' +status: active From 0a4b3a16aed0c96ca57a4340a7e8735d3d274b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 23 Feb 2026 09:54:43 +0100 Subject: [PATCH 038/118] Update docs/features/software-catalog/api.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Fredrik Adelöw --- docs/features/software-catalog/api.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/features/software-catalog/api.md b/docs/features/software-catalog/api.md index 3cd70538c2..97f579c5a3 100644 --- a/docs/features/software-catalog/api.md +++ b/docs/features/software-catalog/api.md @@ -383,20 +383,18 @@ meaning. ``` The other use case is for arrays where you match with a primitive value, such - as labels. Example: + as tags. Example: ```js { - // Works for any field, as long as the value is a primitive - // (either string, number, or boolean) - "metadata.labels": { + // Works for array fields whose items are primitive values + // (typically strings, but numbers and booleans are also supported) + "metadata.tags": { "$contains": "java" } } ``` - In every case, the matching is case insensitive. - ### `GET /entities` Lists entities. From b06bcdcb353ddb93c2291f11f1e30fd37c55a2e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 23 Feb 2026 13:54:29 +0100 Subject: [PATCH 039/118] transfer filters across to the query too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/catalog-client/src/CatalogClient.ts | 38 +++++++-- packages/catalog-client/src/utils.test.ts | 82 +++++++++++++++++++- packages/catalog-client/src/utils.ts | 45 +++++++++++ 3 files changed, 159 insertions(+), 6 deletions(-) diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index bd557cfdd2..2bdf63cc49 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -20,7 +20,8 @@ import { parseEntityRef, stringifyLocationRef, } from '@backstage/catalog-model'; -import { ResponseError } from '@backstage/errors'; +import { InputError, ResponseError } from '@backstage/errors'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { AddLocationRequest, AddLocationResponse, @@ -47,6 +48,7 @@ import { ValidateEntityResponse, } from './types/api'; import { + convertFilterToPredicate, isQueryEntitiesInitialRequest, splitRefsIntoChunks, cursorContainsQuery, @@ -352,11 +354,37 @@ export class CatalogClient implements CatalogApi { const body: QueryEntitiesByPredicateRequest = {}; if (isQueryEntitiesInitialRequest(request)) { - const { query, limit, offset, orderFields, fullTextFilter, fields } = - request; - if (query && typeof query === 'object') { - body.query = query; + const { + filter, + query, + limit, + offset, + orderFields, + fullTextFilter, + fields, + } = 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; + } + if (filterPredicate !== undefined) { + body.query = filterPredicate as unknown as { [key: string]: any }; + } + if (limit !== undefined) { body.limit = limit; } diff --git a/packages/catalog-client/src/utils.test.ts b/packages/catalog-client/src/utils.test.ts index 2f00859985..ce60cc3083 100644 --- a/packages/catalog-client/src/utils.test.ts +++ b/packages/catalog-client/src/utils.test.ts @@ -14,7 +14,87 @@ * limitations under the License. */ -import { splitRefsIntoChunks } from './utils'; +import { CATALOG_FILTER_EXISTS } from './types/api'; +import { convertFilterToPredicate, splitRefsIntoChunks } from './utils'; + +describe('convertFilterToPredicate', () => { + it('converts a single string value', () => { + expect(convertFilterToPredicate({ kind: 'component' })).toEqual({ + kind: 'component', + }); + }); + + it('converts multiple keys into $all', () => { + expect( + convertFilterToPredicate({ + kind: 'component', + 'spec.type': 'service', + }), + ).toEqual({ + $all: [{ kind: 'component' }, { 'spec.type': 'service' }], + }); + }); + + it('converts an array of string values into $in', () => { + expect( + convertFilterToPredicate({ 'spec.type': ['service', 'website'] }), + ).toEqual({ + 'spec.type': { $in: ['service', 'website'] }, + }); + }); + + it('converts CATALOG_FILTER_EXISTS into $exists', () => { + expect( + convertFilterToPredicate({ 'spec.owner': CATALOG_FILTER_EXISTS }), + ).toEqual({ + 'spec.owner': { $exists: true }, + }); + }); + + it('converts an array of records into $any (OR)', () => { + expect( + convertFilterToPredicate([{ kind: 'component' }, { kind: 'api' }]), + ).toEqual({ + $any: [{ kind: 'component' }, { kind: 'api' }], + }); + }); + + it('converts array of records with multiple keys each', () => { + expect( + convertFilterToPredicate([ + { kind: 'component', 'spec.type': 'service' }, + { kind: 'api' }, + ]), + ).toEqual({ + $any: [ + { $all: [{ kind: 'component' }, { 'spec.type': 'service' }] }, + { kind: 'api' }, + ], + }); + }); + + it('treats CATALOG_FILTER_EXISTS mixed with string values as just existence', () => { + expect( + convertFilterToPredicate({ + 'spec.owner': [CATALOG_FILTER_EXISTS, 'team-a'], + }), + ).toEqual({ + 'spec.owner': { $exists: true }, + }); + }); + + it('converts a single-element array filter without wrapping in $any', () => { + expect(convertFilterToPredicate([{ kind: 'component' }])).toEqual({ + kind: 'component', + }); + }); + + it('ignores entries with no valid values', () => { + expect( + convertFilterToPredicate({ kind: 'component', other: [] as string[] }), + ).toEqual({ kind: 'component' }); + }); +}); describe('splitRefsIntoChunks', () => { it('splits by count limit', () => { diff --git a/packages/catalog-client/src/utils.ts b/packages/catalog-client/src/utils.ts index fc867d8670..231aa2409f 100644 --- a/packages/catalog-client/src/utils.ts +++ b/packages/catalog-client/src/utils.ts @@ -14,7 +14,13 @@ * limitations under the License. */ +import type { + FilterPredicate, + FilterPredicateExpression, +} from '@backstage/filter-predicates'; import { + CATALOG_FILTER_EXISTS, + EntityFilterQuery, QueryEntitiesCursorRequest, QueryEntitiesInitialRequest, QueryEntitiesRequest, @@ -39,6 +45,45 @@ export function cursorContainsQuery(cursor: string): boolean { } } +/** + * Converts an {@link EntityFilterQuery} into a predicate query object. + * @internal + */ +export function convertFilterToPredicate(filter: EntityFilterQuery): + | FilterPredicateExpression + | { + $all: FilterPredicate[]; + } + | { + $any: FilterPredicate[]; + } { + const records = [filter].flat(); + + const clauses = records.map(record => { + const parts: FilterPredicateExpression[] = []; + + for (const [key, value] of Object.entries(record)) { + const values = [value].flat(); + const strings = values.filter((v): v is string => typeof v === 'string'); + const hasExists = values.some(v => v === CATALOG_FILTER_EXISTS); + + if (hasExists) { + // Ignore whether there ALSO were some strings - that would boil down to + // just existence anyway since there's effectively an OR between them + parts.push({ [key]: { $exists: true } } as FilterPredicateExpression); + } else if (strings.length === 1) { + parts.push({ [key]: strings[0] } as FilterPredicateExpression); + } else if (strings.length > 1) { + parts.push({ [key]: { $in: strings } } as FilterPredicateExpression); + } + } + + return parts.length === 1 ? parts[0] : { $all: parts }; + }); + + return clauses.length === 1 ? clauses[0] : { $any: clauses }; +} + /** * Takes a set of entity refs, and splits them into chunks (groups) such that * the total string length in each chunk does not exceed the default Express.js From 63ad13904b0bb9e72aa077e621cb9de3ee5f679c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 23 Feb 2026 16:43:36 +0100 Subject: [PATCH 040/118] do not use the very particular 'true' array syntax, but instead the normal one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../request/applyPredicateEntityFilterToQuery.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index a44f878abd..d915b8c4b5 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -175,18 +175,18 @@ function applyFieldCondition(options: { // // FROM: `{ "a": { "$contains": "b" } }` // - // TO: `{ "a.b": "true" }` + // TO: `{ "a": "b" }` // // The search table does not actually show us that "a" was an array to // begin with, so this can mistakenly also match on an object that had a - // "b" key with a true value. We'll consider that an acceptable tradeoff - // though. + // "b" key with a primitive value. We'll consider that an acceptable + // tradeoff though. if (isPrimitive(target)) { const matchQuery = knex('search') .select('search.entity_id') .where({ - key: `${key}.${String(target).toLocaleLowerCase('en-US')}`, - value: 'true', + key, + value: String(target).toLocaleLowerCase('en-US'), }); return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); } From cc91faf318ef5d4c7ea70c729192ab1ea2fb89f9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:10:10 +0000 Subject: [PATCH 041/118] chore(deps): update dependency nodemon to v3.1.13 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index 64f87ae3eb..897069dfa0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -40565,13 +40565,13 @@ __metadata: linkType: hard "nodemon@npm:^3.0.1": - version: 3.1.11 - resolution: "nodemon@npm:3.1.11" + version: 3.1.14 + resolution: "nodemon@npm:3.1.14" dependencies: chokidar: "npm:^3.5.2" debug: "npm:^4" ignore-by-default: "npm:^1.0.1" - minimatch: "npm:^3.1.2" + minimatch: "npm:^10.2.1" pstree.remy: "npm:^1.1.8" semver: "npm:^7.5.3" simple-update-notifier: "npm:^2.0.0" @@ -40580,7 +40580,7 @@ __metadata: undefsafe: "npm:^2.0.5" bin: nodemon: bin/nodemon.js - checksum: 10/0f43d2c70abe0764e26e438dbe0c78bc429746a558dabf5dd94b13f6f3a79b4bd7d5793347acafddaee5eab594a806c2ad43efc999d342e28d185661718da8dc + checksum: 10/187aa93fc461bb001bac5de63df49a38a7b1cb3c60a5fd6a3b0f2b15da68fe5adfd0f91244be2fd1f57263cbd351e3113cb04c4edf4af23699dd95b2782fbf9b languageName: node linkType: hard From 798fea1218651d4a09d513f7745c62d63d47fc1b Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 24 Feb 2026 18:57:56 +0000 Subject: [PATCH 042/118] feat(ui): add Guidelines section with Cards + Table page layout story - Add `packages/ui/src/guidelines/CardsWithTable.stories.tsx` as the first guideline story, showing three data-driven metric cards in a 3-column grid above a paginated table of catalog services. - Update Storybook `storySort` order so the new `Guidelines` group appears at the top of the `Backstage UI` section. - Wrap story content in a `Box bg="neutral-1"` when the Spotify theme is active, preserving `layout: centered` centering and always applying `borderRadius`. - Import `Box` from the UI package into the Storybook preview. - Remove `transition` from `Container` CSS to avoid animation on resize. - Set `padding-inline: 0` on `.bui-Container` inside the Spotify theme so containers render flush within the themed wrapper. Signed-off-by: Charles de Dreuille Co-authored-by: Cursor --- .storybook/preview.tsx | 40 ++- .storybook/themes/spotify.css | 4 + .../components/Container/Container.module.css | 1 - .../src/guidelines/CardsWithTable.stories.tsx | 279 ++++++++++++++++++ 4 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/guidelines/CardsWithTable.stories.tsx diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 6a0177cafe..3d9e197bbc 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -10,6 +10,7 @@ import { apis } from './support/apis'; import { useGlobals } from 'storybook/preview-api'; import { UnifiedThemeProvider, themes } from '@backstage/theme'; import { allModes } from './modes'; +import { Box } from '../packages/ui/src/components/Box'; // Default Backstage theme CSS (from packages/ui) import '../packages/ui/src/css/styles.css'; @@ -70,7 +71,13 @@ export default definePreview({ options: { storySort: { - order: ['Backstage UI', 'Plugins', 'Layout', 'Navigation'], + order: [ + 'Backstage UI', + 'Guidelines', + 'Plugins', + 'Layout', + 'Navigation', + ], }, }, @@ -114,7 +121,7 @@ export default definePreview({ }, decorators: [ - Story => { + (Story, context) => { const [globals] = useGlobals(); const selectedTheme = globals.themeMode === 'light' ? themes.light : themes.dark; @@ -138,7 +145,7 @@ export default definePreview({ (element as HTMLElement).style.backgroundColor = 'var(--bui-bg-app)'; }); - return ( + const content = ( {/* @ts-ignore */} @@ -147,6 +154,33 @@ export default definePreview({ ); + + if (selectedThemeName !== 'spotify') { + return content; + } + + const layout = context.parameters?.layout ?? 'padded'; + const isFullscreen = context.parameters?.layout === 'fullscreen'; + + return ( + + {content} + + ); }, ], diff --git a/.storybook/themes/spotify.css b/.storybook/themes/spotify.css index b72683f53e..89b71c48d3 100644 --- a/.storybook/themes/spotify.css +++ b/.storybook/themes/spotify.css @@ -213,6 +213,10 @@ .bui-Tag { border-radius: var(--bui-radius-full); } + + .bui-Container { + padding-inline: 0; + } } [data-theme-mode='light'][data-theme-name='spotify'] { diff --git a/packages/ui/src/components/Container/Container.module.css b/packages/ui/src/components/Container/Container.module.css index a0dec1e581..ef1d85c66c 100644 --- a/packages/ui/src/components/Container/Container.module.css +++ b/packages/ui/src/components/Container/Container.module.css @@ -21,7 +21,6 @@ max-width: 120rem; padding-inline: var(--bui-space-4); margin-inline: auto; - transition: padding 0.2s ease-in-out; } @media (min-width: 640px) { diff --git a/packages/ui/src/guidelines/CardsWithTable.stories.tsx b/packages/ui/src/guidelines/CardsWithTable.stories.tsx new file mode 100644 index 0000000000..51cc416423 --- /dev/null +++ b/packages/ui/src/guidelines/CardsWithTable.stories.tsx @@ -0,0 +1,279 @@ +/* + * Copyright 2025 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. + */ + +/* eslint-disable no-restricted-syntax */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { MemoryRouter } from 'react-router-dom'; +import { + Card, + CardHeader, + CardBody, + Container, + Grid, + Flex, + Text, + Table, + CellText, + CellProfile, + useTable, + type ColumnConfig, + PluginHeader, + HeaderPage, + Button, +} from '..'; + +// --------------------------------------------------------------------------- +// Metric card data +// --------------------------------------------------------------------------- + +interface MetricCard { + label: string; + value: string; + trend: string; + trendColor: 'primary' | 'secondary' | 'success' | 'danger' | 'warning'; +} + +const metrics: MetricCard[] = [ + { + label: 'Total Components', + value: '142', + trend: '+12 this week', + trendColor: 'success', + }, + { + label: 'Active Services', + value: '58', + trend: '94% healthy', + trendColor: 'success', + }, + { + label: 'Recent Deployments', + value: '23', + trend: 'in the last 24 h', + trendColor: 'secondary', + }, +]; + +// --------------------------------------------------------------------------- +// Table data +// --------------------------------------------------------------------------- + +interface ServiceItem { + id: string; + name: string; + owner: string; + ownerAvatar: string; + type: 'service' | 'library' | 'website' | 'documentation'; + lifecycle: 'production' | 'experimental'; + description: string; +} + +const services: ServiceItem[] = [ + { + id: '1', + name: 'authentication-service', + owner: 'security-team', + ownerAvatar: 'https://github.com/identicons/security-team.png', + type: 'service', + lifecycle: 'production', + description: 'Handles user authentication, sessions and token refresh.', + }, + { + id: '2', + name: 'api-gateway', + owner: 'platform-team', + ownerAvatar: 'https://github.com/identicons/platform-team.png', + type: 'service', + lifecycle: 'production', + description: 'Routes and validates all inbound API requests.', + }, + { + id: '3', + name: 'frontend-core', + owner: 'design-system', + ownerAvatar: 'https://github.com/identicons/design-system.png', + type: 'library', + lifecycle: 'production', + description: 'Shared UI components and design tokens.', + }, + { + id: '4', + name: 'data-pipeline', + owner: 'data-team', + ownerAvatar: 'https://github.com/identicons/data-team.png', + type: 'service', + lifecycle: 'experimental', + description: 'Streaming data ingestion pipeline for analytics.', + }, + { + id: '5', + name: 'developer-portal', + owner: 'devex-team', + ownerAvatar: 'https://github.com/identicons/devex-team.png', + type: 'website', + lifecycle: 'production', + description: 'Internal developer portal built on Backstage.', + }, + { + id: '6', + name: 'notification-service', + owner: 'platform-team', + ownerAvatar: 'https://github.com/identicons/platform-team.png', + type: 'service', + lifecycle: 'production', + description: 'Sends emails, Slack messages and push notifications.', + }, + { + id: '7', + name: 'search-indexer', + owner: 'search-team', + ownerAvatar: 'https://github.com/identicons/search-team.png', + type: 'service', + lifecycle: 'experimental', + description: 'Indexes catalog entities for full-text search.', + }, + { + id: '8', + name: 'billing-service', + owner: 'payments-team', + ownerAvatar: 'https://github.com/identicons/payments-team.png', + type: 'service', + lifecycle: 'production', + description: 'Manages subscriptions, invoices and payment processing.', + }, +]; + +// --------------------------------------------------------------------------- +// Stat card component +// --------------------------------------------------------------------------- + +const StatCard = ({ label, value, trend, trendColor }: MetricCard) => ( + + {label} + + + + {value} + + + {trend} + + + + +); + +// --------------------------------------------------------------------------- +// Page layout component (the actual story render) +// --------------------------------------------------------------------------- + +const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + defaultWidth: '3fr', + cell: item => ( + + ), + }, + { + id: 'owner', + label: 'Owner', + defaultWidth: '2fr', + cell: item => , + }, + { + id: 'type', + label: 'Type', + defaultWidth: '1fr', + cell: item => , + }, + { + id: 'lifecycle', + label: 'Lifecycle', + defaultWidth: '1fr', + cell: item => ( + + ), + }, +]; + +const CardsWithTableLayout = () => { + const { tableProps } = useTable({ + mode: 'complete', + getData: () => services, + paginationOptions: { pageSize: 5 }, + }); + + return ( + <> + + Custom action} + /> + + + + {metrics.map(metric => ( + + ))} + + + + + + ); +}; + +// --------------------------------------------------------------------------- +// Storybook meta +// --------------------------------------------------------------------------- + +const meta = { + title: 'Guidelines/Cards with Table', + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story: () => JSX.Element) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; From 4c2c350490a4f8d83a9ae004521143bd8ec4ccbe Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 24 Feb 2026 19:05:25 +0000 Subject: [PATCH 043/118] Create chatty-wasps-sink.md Signed-off-by: Charles de Dreuille --- .changeset/chatty-wasps-sink.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chatty-wasps-sink.md diff --git a/.changeset/chatty-wasps-sink.md b/.changeset/chatty-wasps-sink.md new file mode 100644 index 0000000000..4b9d5d3525 --- /dev/null +++ b/.changeset/chatty-wasps-sink.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Removed the `transition` on `Container` padding to prevent an unwanted animation when the viewport is resized. From b5fae7405566e61b2699bbc1370b9d7e7afb3f2e Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 24 Feb 2026 19:07:59 +0000 Subject: [PATCH 044/118] Update preview.tsx Signed-off-by: Charles de Dreuille --- .storybook/preview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 3d9e197bbc..9ba9057142 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -168,7 +168,7 @@ export default definePreview({ style={{ minHeight: 'calc(100vh - 32px)', width: 'calc(100vw - 32px)', - borderRadius: '8px', + borderRadius: 'var(--bui-radius-3)', ...(layout === 'centered' && { display: 'flex', alignItems: 'center', From e44b6a9079a0d64dc9b3020485eebbd63dd7bd0c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 24 Feb 2026 13:21:41 +0100 Subject: [PATCH 045/118] cli-common: Remove unused `findOwnDir` and validate `findOwnRootDir` Addresses review feedback from PR #32939: - Remove the `findOwnDir` function which is no longer needed - Add validation to `findOwnRootDir` to verify the resolved path actually contains a monorepo root with a `package.json` that has a `workspaces` field Signed-off-by: Patrik Oldsberg --- .changeset/clean-up-own-root-dir.md | 5 +++++ packages/cli-common/src/paths.test.ts | 10 +++++----- packages/cli-common/src/paths.ts | 23 +++++++++++++++++------ 3 files changed, 27 insertions(+), 11 deletions(-) create mode 100644 .changeset/clean-up-own-root-dir.md diff --git a/.changeset/clean-up-own-root-dir.md b/.changeset/clean-up-own-root-dir.md new file mode 100644 index 0000000000..e702330140 --- /dev/null +++ b/.changeset/clean-up-own-root-dir.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-common': patch +--- + +The `findOwnRootDir` utility now searches for the monorepo root by traversing up the directory tree looking for a `package.json` with `workspaces`, instead of assuming a fixed `../..` relative path. diff --git a/packages/cli-common/src/paths.test.ts b/packages/cli-common/src/paths.test.ts index 6ade904e12..d54dd8838b 100644 --- a/packages/cli-common/src/paths.test.ts +++ b/packages/cli-common/src/paths.test.ts @@ -16,18 +16,18 @@ /* eslint-disable no-restricted-syntax */ import { resolve as resolvePath } from 'node:path'; -import { findPaths, findRootPath, findOwnDir, findOwnRootDir } from './paths'; +import { findPaths, findRootPath, findOwnRootDir, findOwnPaths } from './paths'; describe('paths', () => { afterEach(() => { jest.restoreAllMocks(); }); - it('findOwnDir and findOwnRootDir should find owns paths', () => { - const dir = findOwnDir(__dirname); - const root = findOwnRootDir(dir); + it('findOwnPaths and findOwnRootDir should find own paths', () => { + const own = findOwnPaths(__dirname); + const root = findOwnRootDir(own.dir); - expect(dir).toBe(resolvePath(__dirname, '..')); + expect(own.dir).toBe(resolvePath(__dirname, '..')); expect(root).toBe(resolvePath(__dirname, '../../..')); }); diff --git a/packages/cli-common/src/paths.ts b/packages/cli-common/src/paths.ts index 1cd84d4f5b..c6bedc77e4 100644 --- a/packages/cli-common/src/paths.ts +++ b/packages/cli-common/src/paths.ts @@ -119,7 +119,23 @@ export function findOwnRootDir(ownDir: string) { ); } - return resolvePath(ownDir, '../..'); + const rootDir = findRootPath(ownDir, pkgJsonPath => { + try { + const content = fs.readFileSync(pkgJsonPath, 'utf8'); + const data = JSON.parse(content); + return Boolean(data.workspaces); + } catch (error) { + throw new Error( + `Failed to read package.json at '${pkgJsonPath}', ${error}`, + ); + } + }); + + if (!rootDir) { + throw new Error(`No monorepo root found when searching from '${ownDir}'`); + } + + return rootDir; } // Hierarchical directory cache shared across all OwnPathsImpl instances. @@ -199,11 +215,6 @@ class OwnPathsImpl implements OwnPaths { }; } -// Finds the root of a given package -export function findOwnDir(searchDir: string) { - return OwnPathsImpl.findDir(searchDir); -} - // Used by the test utility in testUtils.ts to override targetPaths export let targetPathsOverride: TargetPaths | undefined; From 59824058ff5c61705ec8fdb1ccde85235e778fbd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 24 Feb 2026 22:21:11 +0100 Subject: [PATCH 046/118] retrigger CI Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor From c7eb58261dc2e77be372661b91303783099302d5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 24 Feb 2026 23:55:15 +0100 Subject: [PATCH 047/118] Update .changeset/clean-up-own-root-dir.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Patrik Oldsberg --- .changeset/clean-up-own-root-dir.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/clean-up-own-root-dir.md b/.changeset/clean-up-own-root-dir.md index e702330140..c668073688 100644 --- a/.changeset/clean-up-own-root-dir.md +++ b/.changeset/clean-up-own-root-dir.md @@ -2,4 +2,4 @@ '@backstage/cli-common': patch --- -The `findOwnRootDir` utility now searches for the monorepo root by traversing up the directory tree looking for a `package.json` with `workspaces`, instead of assuming a fixed `../..` relative path. +The `findOwnRootDir` utility now searches for the monorepo root by traversing up the directory tree looking for a `package.json` with `workspaces`, instead of assuming a fixed `../..` relative path. If no workspaces root is found during this traversal, `findOwnRootDir` now throws to enforce stricter validation of the repository layout. From bf14b6e877cd197c1fa28d0d8ff7aa53601aedd8 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 25 Feb 2026 08:37:48 +0000 Subject: [PATCH 048/118] Update chatty-wasps-sink.md Signed-off-by: Charles de Dreuille --- .changeset/chatty-wasps-sink.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/chatty-wasps-sink.md b/.changeset/chatty-wasps-sink.md index 4b9d5d3525..7f12c5372c 100644 --- a/.changeset/chatty-wasps-sink.md +++ b/.changeset/chatty-wasps-sink.md @@ -3,3 +3,5 @@ --- Removed the `transition` on `Container` padding to prevent an unwanted animation when the viewport is resized. + +Affected components: Container From b42fcdca2e7c8a520454865645e80274310f23c2 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Tue, 24 Feb 2026 12:58:10 +0100 Subject: [PATCH 049/118] feat(ui): replace --bui-bg-popover with layered bg approach Remove the --bui-bg-popover token and adopt a two-layer background pattern across overlay components: outer shell uses --bui-bg-app, inner content uses Box bg="neutral-1". Components updated: Popover, Tooltip, Menu, Dialog. Each component gets a local border-radius CSS variable, a Box content wrapper with neutral-1 background, and updated arrow fills where applicable. Story "is open" variants for each component now use a dot-grid background pattern to detect background transparency issues in Chromatic snapshots. Signed-off-by: Johan Persson --- .changeset/early-baboons-roll.md | 16 ++ docs/conf/user-interface/index.md | 1 - packages/ui/report.api.md | 3 + .../src/components/Dialog/Dialog.module.css | 13 +- .../src/components/Dialog/Dialog.stories.tsx | 18 ++ packages/ui/src/components/Dialog/Dialog.tsx | 8 +- .../ui/src/components/Dialog/definition.ts | 1 + .../ui/src/components/Menu/Menu.module.css | 9 +- .../ui/src/components/Menu/Menu.stories.tsx | 18 ++ packages/ui/src/components/Menu/Menu.tsx | 213 ++++++++++-------- packages/ui/src/components/Menu/definition.ts | 1 + .../src/components/Popover/Popover.module.css | 15 +- .../components/Popover/Popover.stories.tsx | 22 ++ .../ui/src/components/Popover/Popover.tsx | 20 +- .../src/components/Tooltip/Tooltip.module.css | 20 +- .../components/Tooltip/Tooltip.stories.tsx | 18 ++ .../ui/src/components/Tooltip/Tooltip.tsx | 22 +- .../ui/src/components/Tooltip/definition.ts | 1 + packages/ui/src/css/tokens.css | 2 - 19 files changed, 300 insertions(+), 121 deletions(-) create mode 100644 .changeset/early-baboons-roll.md diff --git a/.changeset/early-baboons-roll.md b/.changeset/early-baboons-roll.md new file mode 100644 index 0000000000..bdd55ec507 --- /dev/null +++ b/.changeset/early-baboons-roll.md @@ -0,0 +1,16 @@ +--- +'@backstage/ui': minor +--- + +**BREAKING**: Removed `--bui-bg-popover` CSS token. Popover, Tooltip, Menu, and Dialog now use `--bui-bg-app` for their outer shell and `Box bg="neutral-1"` for content areas, providing better theme consistency and eliminating a redundant token. + +**Migration:** + +Replace any usage of `--bui-bg-popover` with `--bui-bg-neutral-1` (for content surfaces) or `--bui-bg-app` (for outer shells): + +```diff +- background: var(--bui-bg-popover); ++ background: var(--bui-bg-neutral-1); +``` + +**Affected components:** Popover, Tooltip, Menu, Dialog diff --git a/docs/conf/user-interface/index.md b/docs/conf/user-interface/index.md index cb53ebe81c..107fde3d57 100644 --- a/docs/conf/user-interface/index.md +++ b/docs/conf/user-interface/index.md @@ -156,7 +156,6 @@ These colors form a layered neutral scale for your application backgrounds. `--b | Token Name | Description | | ----------------------------- | ------------------------------------------------------------ | | `--bui-bg-app` | The base background color of your Backstage instance. | -| `--bui-bg-popover` | The background color used for popovers, tooltips, and menus. | | `--bui-bg-neutral-1` | First elevated layer. Use for cards, dialogs, and panels. | | `--bui-bg-neutral-1-hover` | Hover state for elements on neutral-1. | | `--bui-bg-neutral-1-pressed` | Pressed state for elements on neutral-1. | diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 5922155e93..25522f1825 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -897,6 +897,7 @@ export const DialogDefinition: { readonly classNames: { readonly overlay: 'bui-DialogOverlay'; readonly dialog: 'bui-Dialog'; + readonly content: 'bui-DialogContent'; readonly header: 'bui-DialogHeader'; readonly headerTitle: 'bui-DialogHeaderTitle'; readonly body: 'bui-DialogBody'; @@ -1331,6 +1332,7 @@ export const MenuDefinition: { readonly classNames: { readonly root: 'bui-Menu'; readonly popover: 'bui-MenuPopover'; + readonly inner: 'bui-MenuInner'; readonly content: 'bui-MenuContent'; readonly section: 'bui-MenuSection'; readonly sectionHeader: 'bui-MenuSectionHeader'; @@ -2218,6 +2220,7 @@ export const Tooltip: ForwardRefExoticComponent< export const TooltipDefinition: { readonly classNames: { readonly tooltip: 'bui-Tooltip'; + readonly content: 'bui-TooltipContent'; readonly arrow: 'bui-TooltipArrow'; }; }; diff --git a/packages/ui/src/components/Dialog/Dialog.module.css b/packages/ui/src/components/Dialog/Dialog.module.css index f5968820a2..da82509134 100644 --- a/packages/ui/src/components/Dialog/Dialog.module.css +++ b/packages/ui/src/components/Dialog/Dialog.module.css @@ -43,8 +43,10 @@ } .bui-Dialog { - background: var(--bui-bg-popover); - border-radius: 0.5rem; + --dialog-border-radius: 0.5rem; + background: var(--bui-bg-app); + box-shadow: var(--bui-shadow); + border-radius: var(--dialog-border-radius); border: 1px solid var(--bui-border-1); color: var(--bui-fg-primary); position: relative; @@ -52,9 +54,14 @@ max-width: calc(100vw - 3rem); height: min(var(--bui-dialog-min-height, auto), calc(100vh - 3rem)); max-height: calc(100vh - 3rem); + outline: none; + } + + .bui-DialogContent { display: flex; flex-direction: column; - outline: none; + border-radius: var(--dialog-border-radius); + height: 100%; } /* Dialog entering animation */ diff --git a/packages/ui/src/components/Dialog/Dialog.stories.tsx b/packages/ui/src/components/Dialog/Dialog.stories.tsx index 0392959a59..9084bfa7e4 100644 --- a/packages/ui/src/components/Dialog/Dialog.stories.tsx +++ b/packages/ui/src/components/Dialog/Dialog.stories.tsx @@ -63,6 +63,24 @@ export const Default = meta.story({ }); export const Open = Default.extend({ + parameters: { layout: 'fullscreen' }, + decorators: [ + Story => ( +
+ +
+ ), + ], args: { defaultOpen: true, }, diff --git a/packages/ui/src/components/Dialog/Dialog.tsx b/packages/ui/src/components/Dialog/Dialog.tsx index 1c33588306..efdc6c4567 100644 --- a/packages/ui/src/components/Dialog/Dialog.tsx +++ b/packages/ui/src/components/Dialog/Dialog.tsx @@ -33,6 +33,7 @@ import { Button } from '../Button'; import { useStyles } from '../../hooks/useStyles'; import { DialogDefinition } from './definition'; import { Flex } from '../Flex'; +import { Box } from '../Box'; import styles from './Dialog.module.css'; /** @public */ @@ -71,7 +72,12 @@ export const Dialog = forwardRef, DialogProps>( ...style, }} > - {children} + + {children} + ); diff --git a/packages/ui/src/components/Dialog/definition.ts b/packages/ui/src/components/Dialog/definition.ts index d2fcb7bb4d..f9aadc9147 100644 --- a/packages/ui/src/components/Dialog/definition.ts +++ b/packages/ui/src/components/Dialog/definition.ts @@ -24,6 +24,7 @@ export const DialogDefinition = { classNames: { overlay: 'bui-DialogOverlay', dialog: 'bui-Dialog', + content: 'bui-DialogContent', header: 'bui-DialogHeader', headerTitle: 'bui-DialogHeaderTitle', body: 'bui-DialogBody', diff --git a/packages/ui/src/components/Menu/Menu.module.css b/packages/ui/src/components/Menu/Menu.module.css index 6dca183395..0717a9468d 100644 --- a/packages/ui/src/components/Menu/Menu.module.css +++ b/packages/ui/src/components/Menu/Menu.module.css @@ -18,12 +18,13 @@ @layer components { .bui-MenuPopover { + --menu-border-radius: var(--bui-radius-2); display: flex; flex-direction: column; box-shadow: var(--bui-shadow); border: 1px solid var(--bui-border-1); - border-radius: var(--bui-radius-2); - background: var(--bui-bg-popover); + border-radius: var(--menu-border-radius); + background: var(--bui-bg-app); color: var(--bui-fg-primary); outline: none; transition: transform 200ms, opacity 200ms; @@ -55,6 +56,10 @@ } } + .bui-MenuInner { + border-radius: var(--menu-border-radius); + } + .bui-MenuContent { max-height: inherit; box-sizing: border-box; diff --git a/packages/ui/src/components/Menu/Menu.stories.tsx b/packages/ui/src/components/Menu/Menu.stories.tsx index ffe184d4ae..982055ade1 100644 --- a/packages/ui/src/components/Menu/Menu.stories.tsx +++ b/packages/ui/src/components/Menu/Menu.stories.tsx @@ -198,6 +198,24 @@ export const PreviewLinks = meta.story({ }); export const Opened = meta.story({ + parameters: { layout: 'fullscreen' }, + decorators: [ + Story => ( +
+ +
+ ), + ], args: { ...Preview.input.args, }, diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx index e02292ba44..da4243f643 100644 --- a/packages/ui/src/components/Menu/Menu.tsx +++ b/packages/ui/src/components/Menu/Menu.tsx @@ -58,6 +58,7 @@ import { } from '../InternalLinkProvider'; import styles from './Menu.module.css'; import clsx from 'clsx'; +import { Box } from '../Box'; const { RoutingProvider, useRoutingRegistrationEffect } = createRoutingRegistration(); @@ -119,18 +120,23 @@ export const Menu = (props: MenuProps) => { )} placement={placement} > - {virtualized ? ( - - {menuContent} - - ) : ( - menuContent - )} + + {virtualized ? ( + + {menuContent} + + ) : ( + menuContent + )} + ); @@ -169,18 +175,23 @@ export const MenuListBox = (props: MenuListBoxProps) => { )} placement={placement} > - {virtualized ? ( - - {listBoxContent} - - ) : ( - listBoxContent - )} + + {virtualized ? ( + + {listBoxContent} + + ) : ( + listBoxContent + )} + ); }; @@ -219,43 +230,48 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps) => { )} placement={placement} > - - + + + + + + + + {virtualized ? ( + + {menuContent} + + ) : ( + menuContent )} - aria-label={props.placeholder || 'Search'} - > - - - - - - {virtualized ? ( - - {menuContent} - - ) : ( - menuContent - )} - + + ); @@ -298,43 +314,48 @@ export const MenuAutocompleteListbox = ( )} placement={placement} > - - + + + + + + + + {virtualized ? ( + + {listBoxContent} + + ) : ( + listBoxContent )} - aria-label={props.placeholder || 'Search'} - > - - - - - - {virtualized ? ( - - {listBoxContent} - - ) : ( - listBoxContent - )} - + + ); }; diff --git a/packages/ui/src/components/Menu/definition.ts b/packages/ui/src/components/Menu/definition.ts index e4ba0d83ab..860ad1d729 100644 --- a/packages/ui/src/components/Menu/definition.ts +++ b/packages/ui/src/components/Menu/definition.ts @@ -24,6 +24,7 @@ export const MenuDefinition = { classNames: { root: 'bui-Menu', popover: 'bui-MenuPopover', + inner: 'bui-MenuInner', content: 'bui-MenuContent', section: 'bui-MenuSection', sectionHeader: 'bui-MenuSectionHeader', diff --git a/packages/ui/src/components/Popover/Popover.module.css b/packages/ui/src/components/Popover/Popover.module.css index 69d2acffcf..cdd323817c 100644 --- a/packages/ui/src/components/Popover/Popover.module.css +++ b/packages/ui/src/components/Popover/Popover.module.css @@ -18,9 +18,10 @@ @layer components { .bui-Popover { + --popover-border-radius: var(--bui-radius-3); box-shadow: var(--bui-shadow); - border-radius: var(--bui-radius-3); - background: var(--bui-bg-popover); + border-radius: var(--popover-border-radius); + background: var(--bui-bg-app); border: 1px solid var(--bui-border-1); forced-color-adjust: none; outline: none; @@ -76,6 +77,7 @@ padding: var(--bui-space-4); flex: 1 1 auto; min-height: 0; + border-radius: var(--popover-border-radius); } .bui-PopoverArrow { @@ -89,11 +91,14 @@ we split the stroke and fill across separate elements in order to guarantee that the stroke is always overlaying a consistent color. */ - path:nth-child(1) { - fill: var(--bui-bg-popover); + use:nth-of-type(1) { + fill: var(--bui-bg-app); + } + use:nth-of-type(2) { + fill: var(--bui-bg-neutral-1); } - path:nth-child(2) { + path { fill: var(--bui-border-1); } diff --git a/packages/ui/src/components/Popover/Popover.stories.tsx b/packages/ui/src/components/Popover/Popover.stories.tsx index ea92bf9d5c..e05b352fc9 100644 --- a/packages/ui/src/components/Popover/Popover.stories.tsx +++ b/packages/ui/src/components/Popover/Popover.stories.tsx @@ -19,6 +19,7 @@ import { Button } from '../Button/Button'; import { DialogTrigger } from '../Dialog/Dialog'; import { Text } from '../Text/Text'; import { Flex } from '../Flex/Flex'; +import { Box } from '../Box'; const meta = preview.meta({ title: 'Backstage UI/Popover', @@ -74,6 +75,24 @@ export const Default = meta.story({ }); export const IsOpen = Default.extend({ + parameters: { layout: 'fullscreen' }, + decorators: [ + Story => ( +
+ +
+ ), + ], args: { isOpen: true, }, @@ -189,6 +208,9 @@ export const WithRichContent = Default.extend({ This is a popover with rich content. It can contain multiple elements and formatted text. + + You can also use the automatic bg system inside it. + - - - - - - + + + + - - Neutral 1 - - - - - - - - - - - Neutral 2 - - - - - - - - - - - Neutral 3 - - - - - - - - + + + + ), @@ -208,20 +146,6 @@ export const Destructive = meta.story({ - - On Neutral 1 - - - - - - Sizes diff --git a/packages/ui/src/components/Container/Container.module.css b/packages/ui/src/components/Container/Container.module.css index ef1d85c66c..b93665525f 100644 --- a/packages/ui/src/components/Container/Container.module.css +++ b/packages/ui/src/components/Container/Container.module.css @@ -21,6 +21,7 @@ max-width: 120rem; padding-inline: var(--bui-space-4); margin-inline: auto; + padding-bottom: var(--bui-space-8); } @media (min-width: 640px) { From 98faba9d21f01a4316d20b37519d71c6841651ce Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 11:17:54 +0100 Subject: [PATCH 052/118] changeset: simplify message to internal refactor Signed-off-by: Patrik Oldsberg --- .changeset/move-clean-pack-to-build.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/move-clean-pack-to-build.md b/.changeset/move-clean-pack-to-build.md index 40db4df15a..9f1969ec41 100644 --- a/.changeset/move-clean-pack-to-build.md +++ b/.changeset/move-clean-pack-to-build.md @@ -2,4 +2,4 @@ '@backstage/cli': patch --- -Moved the `clean` and `pack` commands from the internal maintenance module to the build module. +Internal refactor of CLI command modules. From b11c2cd8e21dfbf0db4976f9a973fdf53eccc402 Mon Sep 17 00:00:00 2001 From: djamaile Date: Wed, 25 Feb 2026 10:57:05 +0100 Subject: [PATCH 053/118] feat(catalog-backend-module-github): prefer verified org emails in default user transformer Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: djamaile --- .changeset/github-org-verified-emails.md | 34 +++++ .../src/lib/defaultTransformers.test.ts | 131 ++++++++++++++++++ .../src/lib/defaultTransformers.ts | 7 +- 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 .changeset/github-org-verified-emails.md create mode 100644 plugins/catalog-backend-module-github/src/lib/defaultTransformers.test.ts diff --git a/.changeset/github-org-verified-emails.md b/.changeset/github-org-verified-emails.md new file mode 100644 index 0000000000..b825395556 --- /dev/null +++ b/.changeset/github-org-verified-emails.md @@ -0,0 +1,34 @@ +--- +'@backstage/plugin-catalog-backend-module-github': minor +--- + +The default user transformer now prefers organization verified domain emails over the user's public GitHub email when populating the user entity profile. It also strips plus-addressed routing tags that GitHub adds to these emails. + +If you want to retain the old behavior, you can do so with a custom user transformer using the `githubOrgEntityProviderTransformsExtensionPoint`: + +```ts +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { githubOrgEntityProviderTransformsExtensionPoint } from '@backstage/plugin-catalog-backend-module-github-org'; +import { defaultUserTransformer } from '@backstage/plugin-catalog-backend-module-github'; + +export default createBackendModule({ + pluginId: 'catalog', + moduleId: 'github-org-custom-transforms', + register(env) { + env.registerInit({ + deps: { + transforms: githubOrgEntityProviderTransformsExtensionPoint, + }, + async init({ transforms }) { + transforms.setUserTransformer(async (item, ctx) => { + const entity = await defaultUserTransformer(item, ctx); + if (entity && item.email) { + entity.spec.profile!.email = item.email; + } + return entity; + }); + }, + }); + }, +}); +``` diff --git a/plugins/catalog-backend-module-github/src/lib/defaultTransformers.test.ts b/plugins/catalog-backend-module-github/src/lib/defaultTransformers.test.ts new file mode 100644 index 0000000000..f90d67bcc9 --- /dev/null +++ b/plugins/catalog-backend-module-github/src/lib/defaultTransformers.test.ts @@ -0,0 +1,131 @@ +/* + * 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 { UserEntity } from '@backstage/catalog-model'; +import { graphql } from '@octokit/graphql'; +import { + defaultUserTransformer, + TransformerContext, +} from './defaultTransformers'; +import { GithubUser } from './github'; + +const ctx: TransformerContext = { + client: graphql, + query: '', + org: 'test-org', +}; + +function makeUser(overrides: Partial = {}): GithubUser { + return { + login: 'testuser', + avatarUrl: '', + ...overrides, + }; +} + +describe('defaultUserTransformer', () => { + it('populates all fields correctly', async () => { + const result = (await defaultUserTransformer( + makeUser({ + name: 'Test User', + email: 'test@example.com', + bio: 'A test bio', + avatarUrl: 'https://example.com/avatar.png', + id: 'user-id-123', + }), + ctx, + )) as UserEntity; + + expect(result.metadata.name).toBe('testuser'); + expect(result.metadata.description).toBe('A test bio'); + expect(result.metadata.annotations).toEqual({ + 'github.com/user-login': 'testuser', + 'github.com/user-id': 'user-id-123', + }); + expect(result.spec.profile).toEqual({ + displayName: 'Test User', + email: 'test@example.com', + picture: 'https://example.com/avatar.png', + }); + expect(result.spec.memberOf).toEqual([]); + }); + + it('prefers verified domain email over regular email', async () => { + const result = (await defaultUserTransformer( + makeUser({ + email: 'public@gmail.com', + organizationVerifiedDomainEmails: ['corp@company.com'], + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('corp@company.com'); + }); + + it('strips plus-addressed tag from verified domain email', async () => { + const result = (await defaultUserTransformer( + makeUser({ + organizationVerifiedDomainEmails: ['amckay+2jc29kv2@spotify.com'], + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('amckay@spotify.com'); + }); + + it('uses verified domain email when regular email is absent', async () => { + const result = (await defaultUserTransformer( + makeUser({ + organizationVerifiedDomainEmails: ['corp@company.com'], + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('corp@company.com'); + }); + + it('falls back to regular email when verified array is empty', async () => { + const result = (await defaultUserTransformer( + makeUser({ + email: 'public@gmail.com', + organizationVerifiedDomainEmails: [], + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('public@gmail.com'); + }); + + it('falls back to regular email when verified array is undefined', async () => { + const result = (await defaultUserTransformer( + makeUser({ + email: 'public@gmail.com', + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('public@gmail.com'); + }); + + it('sets no email when both are absent', async () => { + const result = (await defaultUserTransformer( + makeUser(), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBeUndefined(); + }); +}); diff --git a/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts b/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts index 4de9af89c8..d71cda270d 100644 --- a/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts +++ b/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts @@ -81,7 +81,12 @@ export const defaultUserTransformer = async ( if (item.bio) entity.metadata.description = item.bio; if (item.name) entity.spec.profile!.displayName = item.name; - if (item.email) entity.spec.profile!.email = item.email; + // GitHub returns verified domain emails as plus-addressed routing aliases + // (e.g. user+abc123@example.com). Strip the tag to get the real address. + const email = item.organizationVerifiedDomainEmails?.length + ? item.organizationVerifiedDomainEmails[0].replace(/\+[^@]*/, '') + : item.email; + if (email) entity.spec.profile!.email = email; if (item.avatarUrl) entity.spec.profile!.picture = item.avatarUrl; return entity; }; From 86c424fa8e6c5ea4cb190ad05e66aecdea8dd26d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 11:43:38 +0100 Subject: [PATCH 054/118] chore(cli): move lazy.ts and errors.ts to wiring/ These are CLI framework infrastructure files that belong alongside createCliPlugin and CliInitializer rather than in the general lib/ directory. Signed-off-by: Patrik Oldsberg --- packages/cli/src/modules/build/index.ts | 2 +- packages/cli/src/modules/config/index.ts | 2 +- packages/cli/src/modules/create-github-app/index.ts | 2 +- packages/cli/src/modules/info/index.ts | 2 +- packages/cli/src/modules/lint/index.ts | 2 +- packages/cli/src/modules/maintenance/index.ts | 2 +- packages/cli/src/modules/migrate/index.ts | 2 +- packages/cli/src/modules/new/index.ts | 2 +- packages/cli/src/modules/test/index.ts | 2 +- packages/cli/src/modules/translations/index.ts | 2 +- packages/cli/src/wiring/CliInitializer.ts | 2 +- packages/cli/src/{lib => wiring}/errors.ts | 0 packages/cli/src/{lib => wiring}/lazy.ts | 2 +- 13 files changed, 12 insertions(+), 12 deletions(-) rename packages/cli/src/{lib => wiring}/errors.ts (100%) rename packages/cli/src/{lib => wiring}/lazy.ts (96%) diff --git a/packages/cli/src/modules/build/index.ts b/packages/cli/src/modules/build/index.ts index 9b37efaa8b..1bb01e547e 100644 --- a/packages/cli/src/modules/build/index.ts +++ b/packages/cli/src/modules/build/index.ts @@ -16,7 +16,7 @@ import { Command, Option } from 'commander'; import { createCliPlugin } from '../../wiring/factory'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; import { configOption } from '../config'; export function registerPackageCommands(command: Command) { diff --git a/packages/cli/src/modules/config/index.ts b/packages/cli/src/modules/config/index.ts index 3eabf5e9fc..234e62fde0 100644 --- a/packages/cli/src/modules/config/index.ts +++ b/packages/cli/src/modules/config/index.ts @@ -16,7 +16,7 @@ import { createCliPlugin } from '../../wiring/factory'; import yargs from 'yargs'; import { Command } from 'commander'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; export const configOption = [ '--config ', diff --git a/packages/cli/src/modules/create-github-app/index.ts b/packages/cli/src/modules/create-github-app/index.ts index da1e04aaac..5a3e30e870 100644 --- a/packages/cli/src/modules/create-github-app/index.ts +++ b/packages/cli/src/modules/create-github-app/index.ts @@ -15,7 +15,7 @@ */ import { createCliPlugin } from '../../wiring/factory'; import { Command } from 'commander'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; export default createCliPlugin({ pluginId: 'new', diff --git a/packages/cli/src/modules/info/index.ts b/packages/cli/src/modules/info/index.ts index c14926df85..0919c2ce07 100644 --- a/packages/cli/src/modules/info/index.ts +++ b/packages/cli/src/modules/info/index.ts @@ -15,7 +15,7 @@ */ import yargs from 'yargs'; import { createCliPlugin } from '../../wiring/factory'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; export default createCliPlugin({ pluginId: 'info', diff --git a/packages/cli/src/modules/lint/index.ts b/packages/cli/src/modules/lint/index.ts index 6cba232da9..40c374aa3b 100644 --- a/packages/cli/src/modules/lint/index.ts +++ b/packages/cli/src/modules/lint/index.ts @@ -15,7 +15,7 @@ */ import { createCliPlugin } from '../../wiring/factory'; import { Command } from 'commander'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; export function registerPackageLintCommand(command: Command) { command.arguments('[directories...]'); diff --git a/packages/cli/src/modules/maintenance/index.ts b/packages/cli/src/modules/maintenance/index.ts index 41192afdc4..a658cc3cc7 100644 --- a/packages/cli/src/modules/maintenance/index.ts +++ b/packages/cli/src/modules/maintenance/index.ts @@ -15,7 +15,7 @@ */ import { Command } from 'commander'; import { createCliPlugin } from '../../wiring/factory'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; export default createCliPlugin({ pluginId: 'maintenance', diff --git a/packages/cli/src/modules/migrate/index.ts b/packages/cli/src/modules/migrate/index.ts index ac56c06b7a..d94d04ed8d 100644 --- a/packages/cli/src/modules/migrate/index.ts +++ b/packages/cli/src/modules/migrate/index.ts @@ -15,7 +15,7 @@ */ import { createCliPlugin } from '../../wiring/factory'; import { Command } from 'commander'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; export default createCliPlugin({ pluginId: 'migrate', diff --git a/packages/cli/src/modules/new/index.ts b/packages/cli/src/modules/new/index.ts index 120e77c7c0..ac21b825fe 100644 --- a/packages/cli/src/modules/new/index.ts +++ b/packages/cli/src/modules/new/index.ts @@ -15,7 +15,7 @@ */ import { createCliPlugin } from '../../wiring/factory'; import { Command } from 'commander'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; import { NotImplementedError } from '@backstage/errors'; export default createCliPlugin({ diff --git a/packages/cli/src/modules/test/index.ts b/packages/cli/src/modules/test/index.ts index ecc546aee3..f003d9c054 100644 --- a/packages/cli/src/modules/test/index.ts +++ b/packages/cli/src/modules/test/index.ts @@ -15,7 +15,7 @@ */ import { createCliPlugin } from '../../wiring/factory'; import { Command } from 'commander'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; export default createCliPlugin({ pluginId: 'test', diff --git a/packages/cli/src/modules/translations/index.ts b/packages/cli/src/modules/translations/index.ts index 702b0f4f49..1a700b3505 100644 --- a/packages/cli/src/modules/translations/index.ts +++ b/packages/cli/src/modules/translations/index.ts @@ -15,7 +15,7 @@ */ import yargs from 'yargs'; import { createCliPlugin } from '../../wiring/factory'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; import { DEFAULT_MESSAGE_PATTERN } from './lib/messageFilePath'; export default createCliPlugin({ diff --git a/packages/cli/src/wiring/CliInitializer.ts b/packages/cli/src/wiring/CliInitializer.ts index eab7961b40..8d0b844c78 100644 --- a/packages/cli/src/wiring/CliInitializer.ts +++ b/packages/cli/src/wiring/CliInitializer.ts @@ -20,7 +20,7 @@ import { CommandRegistry } from './CommandRegistry'; import { Command } from 'commander'; import { version } from '../lib/version'; import chalk from 'chalk'; -import { exitWithError } from '../lib/errors'; +import { exitWithError } from './errors'; import { ForwardedError } from '@backstage/errors'; import { isPromise } from 'node:util/types'; diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/wiring/errors.ts similarity index 100% rename from packages/cli/src/lib/errors.ts rename to packages/cli/src/wiring/errors.ts diff --git a/packages/cli/src/lib/lazy.ts b/packages/cli/src/wiring/lazy.ts similarity index 96% rename from packages/cli/src/lib/lazy.ts rename to packages/cli/src/wiring/lazy.ts index d1255ea1bc..64a4d43ef4 100644 --- a/packages/cli/src/lib/lazy.ts +++ b/packages/cli/src/wiring/lazy.ts @@ -15,7 +15,7 @@ */ import { assertError } from '@backstage/errors'; -import { exitWithError } from '../lib/errors'; +import { exitWithError } from './errors'; type ActionFunc = (...args: any[]) => Promise; type ActionExports = { From 61cb9762075b5a40bffd4ce58fd17da354275224 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 11:59:30 +0100 Subject: [PATCH 055/118] Consolidate Lockfile classes: move toString() and versioning utils to cli-node Signed-off-by: Patrik Oldsberg --- .changeset/cli-node-lockfile-tostring.md | 5 + .changeset/cli-versioning-consolidation.md | 5 + packages/cli-node/package.json | 5 +- packages/cli-node/report.api.md | 30 ++++ packages/cli-node/src/index.ts | 1 + .../cli-node/src/monorepo/Lockfile.test.ts | 70 ++++++++- packages/cli-node/src/monorepo/Lockfile.ts | 28 +++- .../lib => cli-node/src}/versioning/index.ts | 4 +- .../src}/versioning/packages.test.ts | 0 .../src}/versioning/packages.ts | 24 ++- .../lib => cli-node/src}/versioning/yarn.ts | 5 + packages/cli/package.json | 3 - packages/cli/src/lib/version.test.ts | 2 +- packages/cli/src/lib/version.ts | 2 +- .../cli/src/lib/versioning/Lockfile.test.ts | 102 ------------- packages/cli/src/lib/versioning/Lockfile.ts | 138 ------------------ .../cli/src/modules/info/commands/info.ts | 7 +- .../migrate/commands/versions/bump.test.ts | 6 +- .../modules/migrate/commands/versions/bump.ts | 4 +- .../new/lib/execution/PortableTemplater.ts | 2 +- yarn.lock | 6 +- 21 files changed, 184 insertions(+), 265 deletions(-) create mode 100644 .changeset/cli-node-lockfile-tostring.md create mode 100644 .changeset/cli-versioning-consolidation.md rename packages/{cli/src/lib => cli-node/src}/versioning/index.ts (85%) rename packages/{cli/src/lib => cli-node/src}/versioning/packages.test.ts (100%) rename packages/{cli/src/lib => cli-node/src}/versioning/packages.ts (89%) rename packages/{cli/src/lib => cli-node/src}/versioning/yarn.ts (95%) delete mode 100644 packages/cli/src/lib/versioning/Lockfile.test.ts delete mode 100644 packages/cli/src/lib/versioning/Lockfile.ts diff --git a/.changeset/cli-node-lockfile-tostring.md b/.changeset/cli-node-lockfile-tostring.md new file mode 100644 index 0000000000..b808002578 --- /dev/null +++ b/.changeset/cli-node-lockfile-tostring.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-node': patch +--- + +Added `toString()` method to `Lockfile` for serializing lockfiles back to string format. Also added new exports: `detectYarnVersion`, `fetchPackageInfo`, `mapDependencies`, and `YarnInfoInspectData`. diff --git a/.changeset/cli-versioning-consolidation.md b/.changeset/cli-versioning-consolidation.md new file mode 100644 index 0000000000..114b931c9b --- /dev/null +++ b/.changeset/cli-versioning-consolidation.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Migrated internal versioning utilities to use `@backstage/cli-node` instead of a local implementation. diff --git a/packages/cli-node/package.json b/packages/cli-node/package.json index 7d198f0a3f..2853733ff2 100644 --- a/packages/cli-node/package.json +++ b/packages/cli-node/package.json @@ -35,14 +35,17 @@ "@backstage/errors": "workspace:^", "@backstage/types": "workspace:^", "@manypkg/get-packages": "^1.1.3", + "@yarnpkg/lockfile": "^1.1.0", "@yarnpkg/parsers": "^3.0.0", "fs-extra": "^11.2.0", + "minimatch": "^10.2.1", "semver": "^7.5.3", "zod": "^3.25.76" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/test-utils": "workspace:^" + "@backstage/test-utils": "workspace:^", + "@types/yarnpkg__lockfile": "^1.1.4" } } diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index b3d8fe5641..9de5d1ffed 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -93,6 +93,12 @@ export type ConcurrentTasksOptions = { worker: (item: TItem) => Promise; }; +// @public +export function detectYarnVersion(dir?: string): Promise<'classic' | 'berry'>; + +// @public +export function fetchPackageInfo(name: string): Promise; + // @public export class GitUtils { static listChangedFiles(ref: string): Promise; @@ -111,6 +117,7 @@ export class Lockfile { keys(): IterableIterator; static load(path: string): Promise; static parse(content: string): Lockfile; + toString(): string; } // @public @@ -133,6 +140,12 @@ export type LockfileQueryEntry = { dataKey: string; }; +// @public +export function mapDependencies( + targetDir: string, + pattern: string, +): Promise>; + // @public export const packageFeatureType: readonly [ '@backstage/BackendFeature', @@ -208,6 +221,13 @@ export class PackageRoles { static getRoleInfo(role: string): PackageRoleInfo; } +// @public +export type PkgVersionInfo = { + range: string; + name: string; + location: string; +}; + // @public export function runConcurrentTasks( options: ConcurrentTasksOptions, @@ -230,4 +250,14 @@ export type WorkerQueueThreadsOptions = { | Promise<(item: TItem) => Promise>; context?: TContext; }; + +// @public +export type YarnInfoInspectData = { + name: string; + 'dist-tags': Record; + versions: string[]; + time: { + [version: string]: string; + }; +}; ``` diff --git a/packages/cli-node/src/index.ts b/packages/cli-node/src/index.ts index 5540666a05..6fc26b19d5 100644 --- a/packages/cli-node/src/index.ts +++ b/packages/cli-node/src/index.ts @@ -24,3 +24,4 @@ export * from './git'; export * from './monorepo'; export * from './concurrency'; export * from './roles'; +export * from './versioning'; diff --git a/packages/cli-node/src/monorepo/Lockfile.test.ts b/packages/cli-node/src/monorepo/Lockfile.test.ts index 03b50fea90..c2d35f7352 100644 --- a/packages/cli-node/src/monorepo/Lockfile.test.ts +++ b/packages/cli-node/src/monorepo/Lockfile.test.ts @@ -15,6 +15,7 @@ */ import { Lockfile } from './Lockfile'; +import { createMockDirectory } from '@backstage/backend-test-utils'; const LEGACY_HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. # yarn lockfile v1 @@ -29,7 +30,74 @@ __metadata: cacheKey: 8 `; -describe('New Lockfile', () => { +const mockLegacy = `${LEGACY_HEADER} +a@^1: + version "1.0.1" + resolved "https://my-registry/a-1.0.01.tgz#abc123" + integrity sha512-xyz + dependencies: + b "^2" + +b@2.0.x: + version "2.0.1" + +b@^2: + version "2.0.0" +`; + +const mockModern = `${MODERN_HEADER} +a@^1: + version: 1.0.1 + dependencies: + b: ^2 + integrity: sha512-xyz + resolved: "https://my-registry/a-1.0.01.tgz#abc123" + +"b@2.0.x, b@^2.0.1": + version: 2.0.1 + +b@^2: + version: 2.0.0 +`; + +describe('Lockfile', () => { + const mockDir = createMockDirectory(); + + it('should load and serialize a legacy lockfile', async () => { + mockDir.setContent({ + 'yarn.lock': mockLegacy, + }); + + const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); + expect(lockfile.get('a')).toEqual([ + { range: '^1', version: '1.0.1', dataKey: 'a@^1' }, + ]); + expect(lockfile.get('b')).toEqual([ + { range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x' }, + { range: '^2', version: '2.0.0', dataKey: 'b@^2' }, + ]); + expect(lockfile.toString()).toBe(mockLegacy); + }); + + it('should load and serialize a modern lockfile', async () => { + mockDir.setContent({ + 'yarn.lock': mockModern, + }); + + const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); + expect(lockfile.get('a')).toEqual([ + { range: '^1', version: '1.0.1', dataKey: 'a@^1' }, + ]); + expect(lockfile.get('b')).toEqual([ + { range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' }, + { range: '^2.0.1', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' }, + { range: '^2', version: '2.0.0', dataKey: 'b@^2' }, + ]); + expect(lockfile.toString()).toBe(mockModern); + }); +}); + +describe('Lockfile advanced', () => { describe('diff', () => { const lockfileLegacyA = Lockfile.parse(`${LEGACY_HEADER} a@^1: diff --git a/packages/cli-node/src/monorepo/Lockfile.ts b/packages/cli-node/src/monorepo/Lockfile.ts index ff624191af..65e22dcf13 100644 --- a/packages/cli-node/src/monorepo/Lockfile.ts +++ b/packages/cli-node/src/monorepo/Lockfile.ts @@ -14,12 +14,22 @@ * limitations under the License. */ -import { parseSyml } from '@yarnpkg/parsers'; +import { parseSyml, stringifySyml } from '@yarnpkg/parsers'; +import { stringify as legacyStringifyLockfile } from '@yarnpkg/lockfile'; import crypto from 'node:crypto'; import fs from 'fs-extra'; const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/; +// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-core/sources/Project.ts#L1741-L1746 +const NEW_HEADER = `${[ + `# This file is generated by running "yarn install" inside your project.\n`, + `# Manual changes might be lost - proceed with caution!\n`, +].join(``)}\n`; + +// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-parsers/sources/syml.ts#L136 +const LEGACY_REGEX = /^(#.*(\r?\n))*?#\s+yarn\s+lockfile\s+v1\r?\n/i; + /** @internal */ type LockfileData = { [entry: string]: { @@ -97,6 +107,8 @@ export class Lockfile { * @public */ static parse(content: string): Lockfile { + const legacy = LEGACY_REGEX.test(content); + let data: LockfileData; try { data = parseSyml(content); @@ -130,18 +142,21 @@ export class Lockfile { } } - return new Lockfile(packages, data); + return new Lockfile(packages, data, legacy); } private readonly packages: Map; private readonly data: LockfileData; + private readonly legacy: boolean; private constructor( packages: Map, data: LockfileData, + legacy: boolean = false, ) { this.packages = packages; this.data = data; + this.legacy = legacy; } /** Returns the name of all packages available in the lockfile */ @@ -154,6 +169,15 @@ export class Lockfile { return this.packages.keys(); } + /** + * Serialize the lockfile back to a string. + */ + toString(): string { + return this.legacy + ? legacyStringifyLockfile(this.data) + : NEW_HEADER + stringifySyml(this.data); + } + /** * Creates a simplified dependency graph from the lockfile data, where each * key is a package, and the value is a set of all packages that it depends on diff --git a/packages/cli/src/lib/versioning/index.ts b/packages/cli-node/src/versioning/index.ts similarity index 85% rename from packages/cli/src/lib/versioning/index.ts rename to packages/cli-node/src/versioning/index.ts index e0b9280ee6..99cbc0803c 100644 --- a/packages/cli/src/lib/versioning/index.ts +++ b/packages/cli-node/src/versioning/index.ts @@ -14,6 +14,6 @@ * limitations under the License. */ -export { Lockfile } from './Lockfile'; +export { detectYarnVersion } from './yarn'; export { fetchPackageInfo, mapDependencies } from './packages'; -export type { YarnInfoInspectData } from './packages'; +export type { PkgVersionInfo, YarnInfoInspectData } from './packages'; diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli-node/src/versioning/packages.test.ts similarity index 100% rename from packages/cli/src/lib/versioning/packages.test.ts rename to packages/cli-node/src/versioning/packages.test.ts diff --git a/packages/cli/src/lib/versioning/packages.ts b/packages/cli-node/src/versioning/packages.ts similarity index 89% rename from packages/cli/src/lib/versioning/packages.ts rename to packages/cli-node/src/versioning/packages.ts index 11f1ba3041..7cfcf132ed 100644 --- a/packages/cli/src/lib/versioning/packages.ts +++ b/packages/cli-node/src/versioning/packages.ts @@ -27,7 +27,11 @@ const DEP_TYPES = [ 'optionalDependencies', ] as const; -// Package data as returned by `yarn info` +/** + * Package data as returned by `yarn info`. + * + * @public + */ export type YarnInfoInspectData = { name: string; 'dist-tags': Record; @@ -41,12 +45,22 @@ type YarnInfo = { data: YarnInfoInspectData | { type: string; data: unknown }; }; -type PkgVersionInfo = { +/** + * Version information for a package dependency. + * + * @public + */ +export type PkgVersionInfo = { range: string; name: string; location: string; }; +/** + * Fetches package information from the registry using `yarn info` or `yarn npm info`. + * + * @public + */ export async function fetchPackageInfo( name: string, ): Promise { @@ -92,7 +106,11 @@ export async function fetchPackageInfo( } } -/** Map all dependencies in the repo as dependency => dependents */ +/** + * Map all dependencies in the repo as dependency to dependents. + * + * @public + */ export async function mapDependencies( targetDir: string, pattern: string, diff --git a/packages/cli/src/lib/versioning/yarn.ts b/packages/cli-node/src/versioning/yarn.ts similarity index 95% rename from packages/cli/src/lib/versioning/yarn.ts rename to packages/cli-node/src/versioning/yarn.ts index 908ddca949..200c8866ce 100644 --- a/packages/cli/src/lib/versioning/yarn.ts +++ b/packages/cli-node/src/versioning/yarn.ts @@ -19,6 +19,11 @@ import { runOutput } from '@backstage/cli-common'; const versions = new Map>(); +/** + * Detects the version of Yarn in use. + * + * @public + */ export function detectYarnVersion(dir?: string): Promise<'classic' | 'berry'> { const cwd = dir ?? process.cwd(); if (versions.has(cwd)) { diff --git a/packages/cli/package.json b/packages/cli/package.json index 27a1b92471..9a4e690e92 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -77,8 +77,6 @@ "@types/webpack-env": "^1.15.2", "@typescript-eslint/eslint-plugin": "^8.17.0", "@typescript-eslint/parser": "^8.16.0", - "@yarnpkg/lockfile": "^1.1.0", - "@yarnpkg/parsers": "^3.0.0", "bfj": "^9.0.2", "buffer": "^6.0.3", "chalk": "^4.0.0", @@ -182,7 +180,6 @@ "@types/tar": "^6.1.1", "@types/terser-webpack-plugin": "^5.0.4", "@types/webpack-sources": "^3.2.3", - "@types/yarnpkg__lockfile": "^1.1.4", "del": "^8.0.0", "esbuild-loader": "^4.0.0", "eslint-webpack-plugin": "^4.2.0", diff --git a/packages/cli/src/lib/version.test.ts b/packages/cli/src/lib/version.test.ts index d582c397c3..86b92e35c3 100644 --- a/packages/cli/src/lib/version.test.ts +++ b/packages/cli/src/lib/version.test.ts @@ -15,7 +15,7 @@ */ import { packageVersions, createPackageVersionProvider } from './version'; -import { Lockfile } from './versioning'; +import { Lockfile } from '@backstage/cli-node'; import corePluginApiPkg from '@backstage/core-plugin-api/package.json'; import { createMockDirectory } from '@backstage/backend-test-utils'; diff --git a/packages/cli/src/lib/version.ts b/packages/cli/src/lib/version.ts index aa8556534f..94337710d6 100644 --- a/packages/cli/src/lib/version.ts +++ b/packages/cli/src/lib/version.ts @@ -17,7 +17,7 @@ import fs from 'fs-extra'; import semver from 'semver'; import { findOwnPaths } from '@backstage/cli-common'; -import { Lockfile } from './versioning'; +import { Lockfile } from '@backstage/cli-node'; /* eslint-disable-next-line no-restricted-syntax */ const ownPaths = findOwnPaths(__dirname); diff --git a/packages/cli/src/lib/versioning/Lockfile.test.ts b/packages/cli/src/lib/versioning/Lockfile.test.ts deleted file mode 100644 index d6996b9143..0000000000 --- a/packages/cli/src/lib/versioning/Lockfile.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2020 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 { Lockfile } from './Lockfile'; -import { createMockDirectory } from '@backstage/backend-test-utils'; - -const LEGACY_HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - -`; - -const MODERN_HEADER = `# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 6 - cacheKey: 8 -`; - -const mockA = `${LEGACY_HEADER} -a@^1: - version "1.0.1" - resolved "https://my-registry/a-1.0.01.tgz#abc123" - integrity sha512-xyz - dependencies: - b "^2" - -b@2.0.x: - version "2.0.1" - -b@^2: - version "2.0.0" -`; - -describe('Lockfile', () => { - const mockDir = createMockDirectory(); - - it('should load and serialize mockA', async () => { - mockDir.setContent({ - 'yarn.lock': mockA, - }); - - const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); - expect(lockfile.get('a')).toEqual([ - { range: '^1', version: '1.0.1', dataKey: 'a@^1' }, - ]); - expect(lockfile.get('b')).toEqual([ - { range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x' }, - { range: '^2', version: '2.0.0', dataKey: 'b@^2' }, - ]); - expect(lockfile.toString()).toBe(mockA); - }); -}); - -const mockANew = `${MODERN_HEADER} -a@^1: - version: 1.0.1 - dependencies: - b: ^2 - integrity: sha512-xyz - resolved: "https://my-registry/a-1.0.01.tgz#abc123" - -"b@2.0.x, b@^2.0.1": - version: 2.0.1 - -b@^2: - version: 2.0.0 -`; - -describe('New Lockfile', () => { - const mockDir = createMockDirectory(); - - it('should load and serialize mockANew', async () => { - mockDir.setContent({ - 'yarn.lock': mockANew, - }); - - const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); - expect(lockfile.get('a')).toEqual([ - { range: '^1', version: '1.0.1', dataKey: 'a@^1' }, - ]); - expect(lockfile.get('b')).toEqual([ - { range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' }, - { range: '^2.0.1', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' }, - { range: '^2', version: '2.0.0', dataKey: 'b@^2' }, - ]); - expect(lockfile.toString()).toBe(mockANew); - }); -}); diff --git a/packages/cli/src/lib/versioning/Lockfile.ts b/packages/cli/src/lib/versioning/Lockfile.ts deleted file mode 100644 index 7a9a2c801a..0000000000 --- a/packages/cli/src/lib/versioning/Lockfile.ts +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright 2020 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 fs from 'fs-extra'; -import { parseSyml, stringifySyml } from '@yarnpkg/parsers'; -import { stringify as legacyStringifyLockfile } from '@yarnpkg/lockfile'; - -const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/; - -type LockfileData = { - [entry: string]: { - version: string; - resolved?: string; - integrity?: string /* old */; - checksum?: string /* new */; - dependencies?: { [name: string]: string }; - peerDependencies?: { [name: string]: string }; - }; -}; - -type LockfileQueryEntry = { - range: string; - version: string; - dataKey: string; -}; - -// the new yarn header is handled out of band of the parsing -// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-core/sources/Project.ts#L1741-L1746 -const NEW_HEADER = `${[ - `# This file is generated by running "yarn install" inside your project.\n`, - `# Manual changes might be lost - proceed with caution!\n`, -].join(``)}\n`; - -// taken from yarn parser package -// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-parsers/sources/syml.ts#L136 -const LEGACY_REGEX = /^(#.*(\r?\n))*?#\s+yarn\s+lockfile\s+v1\r?\n/i; - -// these are special top level yarn keys. -// https://github.com/yarnpkg/berry/blob/9bd61fbffb83d0b8166a9cc26bec3a58743aa453/packages/yarnpkg-parsers/sources/syml.ts#L9 -const SPECIAL_OBJECT_KEYS = [ - `__metadata`, - `version`, - `resolution`, - `dependencies`, - `peerDependencies`, - `dependenciesMeta`, - `peerDependenciesMeta`, - `binaries`, -]; - -export class Lockfile { - static async load(path: string) { - const lockfileContents = await fs.readFile(path, 'utf8'); - return Lockfile.parse(lockfileContents); - } - - static parse(content: string) { - const legacy = LEGACY_REGEX.test(content); - - let data: LockfileData; - try { - data = parseSyml(content); - } catch (err) { - throw new Error(`Failed yarn.lock parse, ${err}`); - } - - const packages = new Map(); - - for (const [key, value] of Object.entries(data)) { - if (SPECIAL_OBJECT_KEYS.includes(key)) continue; - - const [, name, ranges] = ENTRY_PATTERN.exec(key) ?? []; - if (!name) { - throw new Error(`Failed to parse yarn.lock entry '${key}'`); - } - - let queries = packages.get(name); - if (!queries) { - queries = []; - packages.set(name, queries); - } - for (let range of ranges.split(/\s*,\s*/)) { - if (range.startsWith(`${name}@`)) { - range = range.slice(`${name}@`.length); - } - if (range.startsWith('npm:')) { - range = range.slice('npm:'.length); - } - queries.push({ range, version: value.version, dataKey: key }); - } - } - - return new Lockfile(packages, data, legacy); - } - - private readonly packages: Map; - private readonly data: LockfileData; - private readonly legacy: boolean; - - private constructor( - packages: Map, - data: LockfileData, - legacy: boolean = false, - ) { - this.packages = packages; - this.data = data; - this.legacy = legacy; - } - - /** Get the entries for a single package in the lockfile */ - get(name: string): LockfileQueryEntry[] | undefined { - return this.packages.get(name); - } - - /** Returns the name of all packages available in the lockfile */ - keys(): IterableIterator { - return this.packages.keys(); - } - - toString() { - return this.legacy - ? legacyStringifyLockfile(this.data) - : NEW_HEADER + stringifySyml(this.data); - } -} diff --git a/packages/cli/src/modules/info/commands/info.ts b/packages/cli/src/modules/info/commands/info.ts index c50a9da373..c6732e2af1 100644 --- a/packages/cli/src/modules/info/commands/info.ts +++ b/packages/cli/src/modules/info/commands/info.ts @@ -17,8 +17,11 @@ import { version as cliVersion } from '../../../../package.json'; import os from 'node:os'; import { runOutput, targetPaths, findOwnPaths } from '@backstage/cli-common'; -import { Lockfile } from '../../../lib/versioning'; -import { BackstagePackageJson, PackageGraph } from '@backstage/cli-node'; +import { + BackstagePackageJson, + Lockfile, + PackageGraph, +} from '@backstage/cli-node'; import { minimatch } from 'minimatch'; import fs from 'fs-extra'; diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts b/packages/cli/src/modules/migrate/commands/versions/bump.test.ts index cc662f9d5e..7ee65db712 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.test.ts @@ -19,7 +19,7 @@ import * as runObj from '@backstage/cli-common'; import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; import bump, { bumpBackstageJsonVersion, createVersionFinder } from './bump'; import { registerMswTestHooks, withLogCollector } from '@backstage/test-utils'; -import { YarnInfoInspectData } from '../../../../lib/versioning/packages'; +import { YarnInfoInspectData } from '@backstage/cli-node'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; import { NotFoundError } from '@backstage/errors'; @@ -69,8 +69,8 @@ jest.mock('@backstage/cli-common', () => { }); const mockFetchPackageInfo = jest.fn(); -jest.mock('../../../../lib/versioning/packages', () => { - const actual = jest.requireActual('../../../../lib/versioning/packages'); +jest.mock('@backstage/cli-node', () => { + const actual = jest.requireActual('@backstage/cli-node'); return { ...actual, fetchPackageInfo: (name: string) => mockFetchPackageInfo(name), diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.ts b/packages/cli/src/modules/migrate/commands/versions/bump.ts index 4283028216..2f310561b7 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.ts @@ -35,9 +35,9 @@ import { fetchPackageInfo, Lockfile, mapDependencies, + runConcurrentTasks, YarnInfoInspectData, -} from '../../../../lib/versioning'; -import { runConcurrentTasks } from '@backstage/cli-node'; +} from '@backstage/cli-node'; import { getManifestByReleaseLine, getManifestByVersion, diff --git a/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts b/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts index 511e656c91..45478957ac 100644 --- a/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts +++ b/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts @@ -24,7 +24,7 @@ import startCase from 'lodash/startCase'; import upperCase from 'lodash/upperCase'; import upperFirst from 'lodash/upperFirst'; import lowerFirst from 'lodash/lowerFirst'; -import { Lockfile } from '../../../../lib/versioning'; +import { Lockfile } from '@backstage/cli-node'; import { targetPaths } from '@backstage/cli-common'; import { createPackageVersionProvider } from '../../../../lib/version'; diff --git a/yarn.lock b/yarn.lock index 14a3f7453e..3cc1c9c756 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3272,8 +3272,11 @@ __metadata: "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@manypkg/get-packages": "npm:^1.1.3" + "@types/yarnpkg__lockfile": "npm:^1.1.4" + "@yarnpkg/lockfile": "npm:^1.1.0" "@yarnpkg/parsers": "npm:^3.0.0" fs-extra: "npm:^11.2.0" + minimatch: "npm:^10.2.1" semver: "npm:^7.5.3" zod: "npm:^3.25.76" languageName: unknown @@ -3343,11 +3346,8 @@ __metadata: "@types/terser-webpack-plugin": "npm:^5.0.4" "@types/webpack-env": "npm:^1.15.2" "@types/webpack-sources": "npm:^3.2.3" - "@types/yarnpkg__lockfile": "npm:^1.1.4" "@typescript-eslint/eslint-plugin": "npm:^8.17.0" "@typescript-eslint/parser": "npm:^8.16.0" - "@yarnpkg/lockfile": "npm:^1.1.0" - "@yarnpkg/parsers": "npm:^3.0.0" bfj: "npm:^9.0.2" buffer: "npm:^6.0.3" chalk: "npm:^4.0.0" From a27dc7795d8c6e27edf2515c1c788a458a0dce6e Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 25 Feb 2026 12:53:35 +0000 Subject: [PATCH 056/118] feat(ui): simplify neutral bg prop to single auto-incrementing 'neutral' value Signed-off-by: Charles de Dreuille --- .../Accordion/Accordion.stories.tsx | 64 ++++++++-------- .../ui/src/components/Accordion/definition.ts | 2 +- .../ui/src/components/Alert/Alert.stories.tsx | 26 ++++--- .../ui/src/components/Box/Box.stories.tsx | 27 ++++--- .../src/components/Button/Button.stories.tsx | 32 ++++---- .../ui/src/components/Card/Card.stories.tsx | 62 +++++++++------- packages/ui/src/components/Card/Card.tsx | 2 +- .../ui/src/components/Flex/Flex.stories.tsx | 39 +++++----- .../ui/src/components/Grid/Grid.stories.tsx | 73 ++++++++++--------- .../ToggleButton/ToggleButton.stories.tsx | 21 ++++-- .../ToggleButtonGroup.stories.tsx | 49 +++++++------ packages/ui/src/hooks/useBg.tsx | 10 +-- packages/ui/src/types.ts | 16 ++-- 13 files changed, 237 insertions(+), 186 deletions(-) diff --git a/packages/ui/src/components/Accordion/Accordion.stories.tsx b/packages/ui/src/components/Accordion/Accordion.stories.tsx index b88b547e08..5def4f57d3 100644 --- a/packages/ui/src/components/Accordion/Accordion.stories.tsx +++ b/packages/ui/src/components/Accordion/Accordion.stories.tsx @@ -195,7 +195,7 @@ export const AutoBg = meta.story({ - + Neutral 1 container @@ -210,35 +210,41 @@ export const AutoBg = meta.story({ - - Neutral 2 container - - - - - - - - - - - - + + + Neutral 2 container + + + + + + + + + + + + + - - Neutral 3 container - - - - - - - - - - - - + + + + Neutral 3 container + + + + + + + + + + + + + + ), diff --git a/packages/ui/src/components/Accordion/definition.ts b/packages/ui/src/components/Accordion/definition.ts index 69ef9be3a7..2e76f0cd77 100644 --- a/packages/ui/src/components/Accordion/definition.ts +++ b/packages/ui/src/components/Accordion/definition.ts @@ -34,7 +34,7 @@ export const AccordionDefinition = defineComponent()({ }, bg: 'provider', propDefs: { - bg: { dataAttribute: true, default: 'neutral-auto' }, + bg: { dataAttribute: true, default: 'neutral' }, children: {}, className: {}, }, diff --git a/packages/ui/src/components/Alert/Alert.stories.tsx b/packages/ui/src/components/Alert/Alert.stories.tsx index 28239becb9..589d55b7dd 100644 --- a/packages/ui/src/components/Alert/Alert.stories.tsx +++ b/packages/ui/src/components/Alert/Alert.stories.tsx @@ -297,7 +297,7 @@ export const OnDifferentBackgrounds = meta.story({ On Neutral 1 - + @@ -305,18 +305,24 @@ export const OnDifferentBackgrounds = meta.story({ On Neutral 2 - - - - + + + + + + On Neutral 3 - - - - + + + + + + + + ), @@ -337,7 +343,7 @@ export const Responsive = meta.story({ export const WithUtilityProps = meta.story({ render: () => ( - + ( Default - - Neutral 1 + + Neutral (level 1) - - Neutral 2 + + + Neutral (level 2) + - - Neutral 3 - - - Responsive Neutral + + + + Neutral (level 3) + + Danger @@ -381,11 +384,11 @@ export const BackgroundColors = meta.story({ export const NestedNeutralColors = meta.story({ args: { px: '6', py: '4', children: null }, render: args => ( - + - + - + diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx index 2e2f94b4e1..5781daca6a 100644 --- a/packages/ui/src/components/Button/Button.stories.tsx +++ b/packages/ui/src/components/Button/Button.stories.tsx @@ -357,26 +357,32 @@ export const AutoBg = meta.story({ neutral level by 1. No prop is needed on the button -- it's fully automatic. - + Neutral 1 container - - Neutral 2 container - - - - + + + Neutral 2 container + + + + + - - Neutral 3 container - - - - + + + + Neutral 3 container + + + + + + ), diff --git a/packages/ui/src/components/Card/Card.stories.tsx b/packages/ui/src/components/Card/Card.stories.tsx index 3957b7beff..87671c4cf6 100644 --- a/packages/ui/src/components/Card/Card.stories.tsx +++ b/packages/ui/src/components/Card/Card.stories.tsx @@ -135,23 +135,29 @@ export const Backgrounds = meta.story({ No parent Defaults to neutral-1 - + On neutral-1 Auto-increments to neutral-2 - - - On neutral-2 - Auto-increments to neutral-3 - + + + + On neutral-2 + Auto-increments to neutral-3 + + - - - On neutral-3 - Steps up to neutral-4 - + + + + + On neutral-3 + Steps up to neutral-4 + + + ), @@ -197,23 +203,29 @@ export const BgOnProviders = meta.story({ No provider Card defaults to neutral-1 - + On neutral-1 Card auto-increments to neutral-2 - - - On neutral-2 - Card auto-increments to neutral-3 - + + + + On neutral-2 + Card auto-increments to neutral-3 + + - - - On neutral-3 - Card visually at neutral-4 - + + + + + On neutral-3 + Card visually at neutral-4 + + + ), @@ -226,11 +238,7 @@ export const CustomCardWithBox = meta.story({ A custom card built with Box. Use Box with an explicit bg prop to create a card-like container that participates in the bg system as a provider. - + diff --git a/packages/ui/src/components/Card/Card.tsx b/packages/ui/src/components/Card/Card.tsx index e3e4781eac..5eb09f2edc 100644 --- a/packages/ui/src/components/Card/Card.tsx +++ b/packages/ui/src/components/Card/Card.tsx @@ -44,7 +44,7 @@ export const Card = forwardRef((props, ref) => { return ( ( Default - - Neutral 1 - - - Neutral 2 - - - Neutral 3 - - - Responsive Bg + + Neutral (level 1) + + + Neutral (level 2) + + + + + + Neutral (level 3) + + + Danger @@ -278,20 +281,20 @@ export const Backgrounds = meta.story({ ), }); -export const BgNeutralAuto = meta.story({ +export const BgNeutral = meta.story({ args: { px: '6', py: '4', gap: '4' }, render: args => (
- Using bg="neutral-auto" on Flex auto-increments from the parent context. - The first Flex defaults to neutral-1 (no parent), then each nested Flex + Using bg="neutral" on Flex auto-increments from the parent context. The + first Flex defaults to neutral-1 (no parent), then each nested Flex increments by one, capping at neutral-3.
- -
Neutral 1 (auto, no parent)
- + +
Neutral 1 (no parent)
+
Neutral 2 (auto-incremented)
- +
Neutral 3 (auto-incremented, capped)
diff --git a/packages/ui/src/components/Grid/Grid.stories.tsx b/packages/ui/src/components/Grid/Grid.stories.tsx index b3b6957359..f60517bc31 100644 --- a/packages/ui/src/components/Grid/Grid.stories.tsx +++ b/packages/ui/src/components/Grid/Grid.stories.tsx @@ -113,18 +113,21 @@ export const Backgrounds = meta.story({ render: args => ( - - Neutral 1 - - - Neutral 2 - - - Neutral 3 - - - Responsive Bg + + Neutral (level 1) + + + Neutral (level 2) + + + + + + Neutral (level 3) + + + Danger @@ -137,28 +140,26 @@ export const Backgrounds = meta.story({ - - Neutral 1 - - - - - Neutral 2 - - - - - Neutral 3 - - - - - Responsive Bg + + Neutral (level 1) + + + + Neutral (level 2) + + + + + + + + Neutral (level 3) + + + + Danger @@ -179,7 +180,7 @@ export const Backgrounds = meta.story({ ), }); -export const BgNeutralAuto = meta.story({ +export const BgNeutral = meta.story({ args: { px: '6', py: '4', columns: '2', gap: '4' }, render: args => ( @@ -188,12 +189,12 @@ export const BgNeutralAuto = meta.story({ default. Only an explicit bg prop establishes a new bg level. Nested grids without a bg prop inherit the parent context unchanged. - + Neutral 1 (Grid.Root) - - Nested: neutral-2 (explicit) - Nested: neutral-2 (explicit) + + Nested: neutral-2 (auto-incremented) + Nested: neutral-2 (auto-incremented) diff --git a/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx b/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx index a89bbe3862..876ff654b0 100644 --- a/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx +++ b/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx @@ -16,6 +16,7 @@ import preview from '../../../../../.storybook/preview'; import { ToggleButton } from './ToggleButton'; +import { Box } from '../Box'; import { Flex } from '../Flex'; import { Text } from '../Text'; import { useState } from 'react'; @@ -65,21 +66,27 @@ export const Backgrounds = meta.story({ On Neutral 1 - + Toggle On Neutral 2 - - Toggle - + + + Toggle + + On Neutral 3 - - Toggle - + + + + Toggle + + + ), diff --git a/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx index 617cd0c0fa..c98a2e18f1 100644 --- a/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx +++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx @@ -17,6 +17,7 @@ import preview from '../../../../../.storybook/preview'; import { ToggleButtonGroup } from './ToggleButtonGroup'; import { ToggleButton } from '../ToggleButton/ToggleButton'; +import { Box } from '../Box'; import { Flex } from '../Flex'; import { Text } from '../Text'; import { useState } from 'react'; @@ -100,7 +101,7 @@ export const Backgrounds = meta.story({ On Neutral 1 - + On Neutral 2 - - - Option 1 - Option 2 - Option 3 - - + + + + Option 1 + Option 2 + Option 3 + + + On Neutral 3 - - - Option 1 - Option 2 - Option 3 - - + + + + + Option 1 + Option 2 + Option 3 + + + + ), diff --git a/packages/ui/src/hooks/useBg.tsx b/packages/ui/src/hooks/useBg.tsx index 6e298fedf9..ea9376b039 100644 --- a/packages/ui/src/hooks/useBg.tsx +++ b/packages/ui/src/hooks/useBg.tsx @@ -90,10 +90,10 @@ export function useBgConsumer(): BgContextValue { * * - `bg` is `undefined` -- transparent, no context change, returns `{ bg: undefined }`. * This is the default for Box, Flex, and Grid (they do **not** auto-increment). - * - `bg` is a `ContainerBg` value -- uses that value directly (e.g. `'neutral-1'`). - * - `bg` is `'neutral-auto'` -- increments the neutral level from the parent context, - * capping at `neutral-3`. Only components that explicitly pass `'neutral-auto'` - * (e.g. Card) will auto-increment; it is never implicit. + * - `bg` is `'neutral'` -- increments the neutral level from the parent context, + * capping at `neutral-3`. The increment is always relative to the parent; it is + * not possible to pin a container to an explicit neutral level. + * - `bg` is `'danger'` | `'warning'` | `'success'` -- used as-is. * * **Capping:** * @@ -116,7 +116,7 @@ export function useBgProvider(bg?: Responsive): BgContextValue { const resolved = resolveResponsiveValue(bg, breakpoint); - if (resolved === 'neutral-auto') { + if (resolved === 'neutral') { return { bg: incrementNeutralBg(context.bg) }; } diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index d3dfd996aa..05ef25d5dc 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -182,7 +182,7 @@ export interface ComponentDefinition { } /** - * Background type for the neutral bg system. + * Resolved background level stored in context and applied as `data-bg` on DOM elements. * * Supports neutral levels ('neutral-1' through 'neutral-3') and * intent backgrounds ('danger', 'warning', 'success'). @@ -190,7 +190,9 @@ export interface ComponentDefinition { * The 'neutral-4' level is not exposed as a prop value -- it is reserved * for leaf component CSS (e.g. Button on a 'neutral-3' surface). * - * @public + * This type is internal. Use `ProviderBg` for component prop types. + * + * @internal */ export type ContainerBg = | 'neutral-1' @@ -201,11 +203,13 @@ export type ContainerBg = | 'success'; /** - * Background values accepted by provider components. + * Background values accepted by provider components (Box, Flex, Grid, Card, etc.). * - * Includes all `ContainerBg` values plus `'neutral-auto'` which - * automatically increments the neutral level from the parent context. + * - `'neutral'` — automatically increments the neutral level from the parent context, + * capping at the maximum level. This is always incremental; explicit levels cannot + * be set directly. + * - `'danger'` | `'warning'` | `'success'` — intent backgrounds used as-is. * * @public */ -export type ProviderBg = ContainerBg | 'neutral-auto'; +export type ProviderBg = 'neutral' | 'danger' | 'warning' | 'success'; From 503393640184a7d95e2273f6e16e2d1e279ea957 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 14:09:25 +0100 Subject: [PATCH 057/118] Keep versioning utilities internal to CLI migrate module Signed-off-by: Patrik Oldsberg --- .changeset/cli-node-lockfile-tostring.md | 2 +- packages/cli-node/package.json | 1 - packages/cli-node/report.api.md | 29 ------------------- packages/cli-node/src/index.ts | 1 - packages/cli-node/src/versioning/index.ts | 19 ------------ .../migrate/commands/versions/bump.test.ts | 6 ++-- .../modules/migrate/commands/versions/bump.ts | 5 ++-- .../migrate/lib}/versioning/packages.test.ts | 0 .../migrate/lib}/versioning/packages.ts | 24 ++------------- .../modules/migrate/lib}/versioning/yarn.ts | 5 ---- yarn.lock | 1 - 11 files changed, 9 insertions(+), 84 deletions(-) delete mode 100644 packages/cli-node/src/versioning/index.ts rename packages/{cli-node/src => cli/src/modules/migrate/lib}/versioning/packages.test.ts (100%) rename packages/{cli-node/src => cli/src/modules/migrate/lib}/versioning/packages.ts (89%) rename packages/{cli-node/src => cli/src/modules/migrate/lib}/versioning/yarn.ts (95%) diff --git a/.changeset/cli-node-lockfile-tostring.md b/.changeset/cli-node-lockfile-tostring.md index b808002578..bf4fc31225 100644 --- a/.changeset/cli-node-lockfile-tostring.md +++ b/.changeset/cli-node-lockfile-tostring.md @@ -2,4 +2,4 @@ '@backstage/cli-node': patch --- -Added `toString()` method to `Lockfile` for serializing lockfiles back to string format. Also added new exports: `detectYarnVersion`, `fetchPackageInfo`, `mapDependencies`, and `YarnInfoInspectData`. +Added `toString()` method to `Lockfile` for serializing lockfiles back to string format. diff --git a/packages/cli-node/package.json b/packages/cli-node/package.json index 2853733ff2..014ed56992 100644 --- a/packages/cli-node/package.json +++ b/packages/cli-node/package.json @@ -38,7 +38,6 @@ "@yarnpkg/lockfile": "^1.1.0", "@yarnpkg/parsers": "^3.0.0", "fs-extra": "^11.2.0", - "minimatch": "^10.2.1", "semver": "^7.5.3", "zod": "^3.25.76" }, diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index 9de5d1ffed..cf27d92894 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -93,12 +93,6 @@ export type ConcurrentTasksOptions = { worker: (item: TItem) => Promise; }; -// @public -export function detectYarnVersion(dir?: string): Promise<'classic' | 'berry'>; - -// @public -export function fetchPackageInfo(name: string): Promise; - // @public export class GitUtils { static listChangedFiles(ref: string): Promise; @@ -140,12 +134,6 @@ export type LockfileQueryEntry = { dataKey: string; }; -// @public -export function mapDependencies( - targetDir: string, - pattern: string, -): Promise>; - // @public export const packageFeatureType: readonly [ '@backstage/BackendFeature', @@ -221,13 +209,6 @@ export class PackageRoles { static getRoleInfo(role: string): PackageRoleInfo; } -// @public -export type PkgVersionInfo = { - range: string; - name: string; - location: string; -}; - // @public export function runConcurrentTasks( options: ConcurrentTasksOptions, @@ -250,14 +231,4 @@ export type WorkerQueueThreadsOptions = { | Promise<(item: TItem) => Promise>; context?: TContext; }; - -// @public -export type YarnInfoInspectData = { - name: string; - 'dist-tags': Record; - versions: string[]; - time: { - [version: string]: string; - }; -}; ``` diff --git a/packages/cli-node/src/index.ts b/packages/cli-node/src/index.ts index 6fc26b19d5..5540666a05 100644 --- a/packages/cli-node/src/index.ts +++ b/packages/cli-node/src/index.ts @@ -24,4 +24,3 @@ export * from './git'; export * from './monorepo'; export * from './concurrency'; export * from './roles'; -export * from './versioning'; diff --git a/packages/cli-node/src/versioning/index.ts b/packages/cli-node/src/versioning/index.ts deleted file mode 100644 index 99cbc0803c..0000000000 --- a/packages/cli-node/src/versioning/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright 2020 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. - */ - -export { detectYarnVersion } from './yarn'; -export { fetchPackageInfo, mapDependencies } from './packages'; -export type { PkgVersionInfo, YarnInfoInspectData } from './packages'; diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts b/packages/cli/src/modules/migrate/commands/versions/bump.test.ts index 7ee65db712..0061d07eba 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.test.ts @@ -19,7 +19,7 @@ import * as runObj from '@backstage/cli-common'; import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; import bump, { bumpBackstageJsonVersion, createVersionFinder } from './bump'; import { registerMswTestHooks, withLogCollector } from '@backstage/test-utils'; -import { YarnInfoInspectData } from '@backstage/cli-node'; +import { YarnInfoInspectData } from '../../lib/versioning/packages'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; import { NotFoundError } from '@backstage/errors'; @@ -69,8 +69,8 @@ jest.mock('@backstage/cli-common', () => { }); const mockFetchPackageInfo = jest.fn(); -jest.mock('@backstage/cli-node', () => { - const actual = jest.requireActual('@backstage/cli-node'); +jest.mock('../../lib/versioning/packages', () => { + const actual = jest.requireActual('../../lib/versioning/packages'); return { ...actual, fetchPackageInfo: (name: string) => mockFetchPackageInfo(name), diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.ts b/packages/cli/src/modules/migrate/commands/versions/bump.ts index 2f310561b7..7ecd908d8a 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.ts @@ -31,13 +31,12 @@ import { isError, NotFoundError } from '@backstage/errors'; import { resolve as resolvePath } from 'node:path'; import { getHasYarnPlugin } from '../../../../lib/yarnPlugin'; +import { Lockfile, runConcurrentTasks } from '@backstage/cli-node'; import { fetchPackageInfo, - Lockfile, mapDependencies, - runConcurrentTasks, YarnInfoInspectData, -} from '@backstage/cli-node'; +} from '../../lib/versioning/packages'; import { getManifestByReleaseLine, getManifestByVersion, diff --git a/packages/cli-node/src/versioning/packages.test.ts b/packages/cli/src/modules/migrate/lib/versioning/packages.test.ts similarity index 100% rename from packages/cli-node/src/versioning/packages.test.ts rename to packages/cli/src/modules/migrate/lib/versioning/packages.test.ts diff --git a/packages/cli-node/src/versioning/packages.ts b/packages/cli/src/modules/migrate/lib/versioning/packages.ts similarity index 89% rename from packages/cli-node/src/versioning/packages.ts rename to packages/cli/src/modules/migrate/lib/versioning/packages.ts index 7cfcf132ed..11f1ba3041 100644 --- a/packages/cli-node/src/versioning/packages.ts +++ b/packages/cli/src/modules/migrate/lib/versioning/packages.ts @@ -27,11 +27,7 @@ const DEP_TYPES = [ 'optionalDependencies', ] as const; -/** - * Package data as returned by `yarn info`. - * - * @public - */ +// Package data as returned by `yarn info` export type YarnInfoInspectData = { name: string; 'dist-tags': Record; @@ -45,22 +41,12 @@ type YarnInfo = { data: YarnInfoInspectData | { type: string; data: unknown }; }; -/** - * Version information for a package dependency. - * - * @public - */ -export type PkgVersionInfo = { +type PkgVersionInfo = { range: string; name: string; location: string; }; -/** - * Fetches package information from the registry using `yarn info` or `yarn npm info`. - * - * @public - */ export async function fetchPackageInfo( name: string, ): Promise { @@ -106,11 +92,7 @@ export async function fetchPackageInfo( } } -/** - * Map all dependencies in the repo as dependency to dependents. - * - * @public - */ +/** Map all dependencies in the repo as dependency => dependents */ export async function mapDependencies( targetDir: string, pattern: string, diff --git a/packages/cli-node/src/versioning/yarn.ts b/packages/cli/src/modules/migrate/lib/versioning/yarn.ts similarity index 95% rename from packages/cli-node/src/versioning/yarn.ts rename to packages/cli/src/modules/migrate/lib/versioning/yarn.ts index 200c8866ce..908ddca949 100644 --- a/packages/cli-node/src/versioning/yarn.ts +++ b/packages/cli/src/modules/migrate/lib/versioning/yarn.ts @@ -19,11 +19,6 @@ import { runOutput } from '@backstage/cli-common'; const versions = new Map>(); -/** - * Detects the version of Yarn in use. - * - * @public - */ export function detectYarnVersion(dir?: string): Promise<'classic' | 'berry'> { const cwd = dir ?? process.cwd(); if (versions.has(cwd)) { diff --git a/yarn.lock b/yarn.lock index 3cc1c9c756..74484a0056 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3276,7 +3276,6 @@ __metadata: "@yarnpkg/lockfile": "npm:^1.1.0" "@yarnpkg/parsers": "npm:^3.0.0" fs-extra: "npm:^11.2.0" - minimatch: "npm:^10.2.1" semver: "npm:^7.5.3" zod: "npm:^3.25.76" languageName: unknown From f7ada5ce14f592b4edfab39f7750de1bd02d92ca Mon Sep 17 00:00:00 2001 From: Rinke Hoekstra Date: Wed, 25 Feb 2026 14:46:33 +0100 Subject: [PATCH 058/118] Fix capitalization in resource description Corrected capitalization of 'A Resource' at the beginning of the sentence (as it is a proper noun) Signed-off-by: Rinke Hoekstra --- docs/features/software-catalog/descriptor-format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index ac8baadad5..7fdb9056ab 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -1121,7 +1121,7 @@ Describes the following entity kind: | `apiVersion` | `backstage.io/v1alpha1` | | `kind` | `Resource` | -A resource describes the infrastructure a system needs to operate, like BigTable +A Resource describes the infrastructure a system needs to operate, like BigTable databases, Pub/Sub topics, S3 buckets or CDNs. Modelling them together with components and systems allows to visualize resource footprint, and create tooling around them. From 07ba74684a5f0c7e416aab127ea3f23f0f7feae9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 14:50:20 +0100 Subject: [PATCH 059/118] catalog: fix entity page tab group ordering The entity page tab groups were rendered in extension tree order rather than respecting the order defined in the groups configuration. This sorts the rendered groups based on the key order from groupDefinitions. Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- .changeset/fix-entity-tab-group-ordering.md | 5 +++ .../components/EntityTabs/EntityTabsList.tsx | 44 ++++++++++++------- 2 files changed, 34 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-entity-tab-group-ordering.md diff --git a/.changeset/fix-entity-tab-group-ordering.md b/.changeset/fix-entity-tab-group-ordering.md new file mode 100644 index 0000000000..6fab3f77af --- /dev/null +++ b/.changeset/fix-entity-tab-group-ordering.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Fixed entity page tab groups not respecting the ordering from the `groups` configuration. diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx index d77cc5e444..9267e083f5 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx @@ -88,20 +88,34 @@ export function EntityTabsList(props: EntityTabsListProps) { const { tabs: items, selectedIndex = 0, showIcons, groupDefinitions } = props; - const groups = useMemo( - () => - items.reduce((result, tab) => { - const group = tab.group ? groupDefinitions[tab.group] : undefined; - const groupOrId = group && tab.group ? tab.group : tab.id; - result[groupOrId] = result[groupOrId] ?? { - group, - items: [], - }; - result[groupOrId].items.push(tab); - return result; - }, {} as Record), - [items, groupDefinitions], - ); + const groups = useMemo(() => { + const byKey = items.reduce((result, tab) => { + const group = tab.group ? groupDefinitions[tab.group] : undefined; + const groupOrId = group && tab.group ? tab.group : tab.id; + result[groupOrId] = result[groupOrId] ?? { + group, + items: [], + }; + result[groupOrId].items.push(tab); + return result; + }, {} as Record); + + const groupOrder = Object.keys(groupDefinitions); + return Object.entries(byKey).sort(([a], [b]) => { + const ai = groupOrder.indexOf(a); + const bi = groupOrder.indexOf(b); + if (ai !== -1 && bi !== -1) { + return ai - bi; + } + if (ai !== -1) { + return -1; + } + if (bi !== -1) { + return 1; + } + return 0; + }); + }, [items, groupDefinitions]); const selectedItem = items[selectedIndex]; return ( @@ -115,7 +129,7 @@ export function EntityTabsList(props: EntityTabsListProps) { aria-label={t('entityTabs.tabsAriaLabel')} value={selectedItem?.group ?? selectedItem?.id} > - {Object.entries(groups).map(([id, tabGroup]) => ( + {groups.map(([id, tabGroup]) => ( Date: Wed, 25 Feb 2026 14:51:28 +0100 Subject: [PATCH 060/118] patches: add patch for #33004 Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- .patches/pr-33004.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 .patches/pr-33004.txt diff --git a/.patches/pr-33004.txt b/.patches/pr-33004.txt new file mode 100644 index 0000000000..5594a76e6b --- /dev/null +++ b/.patches/pr-33004.txt @@ -0,0 +1 @@ +Fixes entity page tab groups not respecting the ordering from the groups configuration. \ No newline at end of file From f0fc4ab8de07b9befec93878aba325703fc13886 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 15:05:30 +0100 Subject: [PATCH 061/118] catalog: add tests for entity tab group ordering Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- .../components/EntityTabs/EntityTabs.test.tsx | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.test.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.test.tsx index 3bca08288f..a85d795cd8 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.test.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.test.tsx @@ -16,8 +16,10 @@ import { screen } from '@testing-library/react'; import { useSelectedSubRoute } from './EntityTabs'; +import { EntityTabsList } from './EntityTabsList'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { render } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/frontend-test-utils'; function TestSubRouteHook(props: { subRoutes: Array<{ @@ -37,6 +39,83 @@ function TestSubRouteHook(props: { ); } +describe('EntityTabsList', () => { + it('should render groups in the order defined by groupDefinitions', () => { + const tabs = [ + { id: '/cicd', label: 'CI/CD', path: 'cicd', group: 'cicd' }, + { + id: '/overview', + label: 'Overview', + path: 'overview', + group: 'overview', + }, + { + id: '/techdocs', + label: 'TechDocs', + path: 'techdocs', + group: 'techdocs', + }, + ]; + + const groupDefinitions = { + overview: { title: 'Overview' }, + techdocs: { title: 'TechDocs' }, + cicd: { title: 'CI/CD' }, + }; + + renderInTestApp( + , + ); + + const tabElements = screen.getAllByRole('tab'); + expect(tabElements).toHaveLength(3); + expect(tabElements[0]).toHaveTextContent('Overview'); + expect(tabElements[1]).toHaveTextContent('TechDocs'); + expect(tabElements[2]).toHaveTextContent('CI/CD'); + }); + + it('should place ungrouped tabs after defined groups', () => { + const tabs = [ + { id: '/standalone', label: 'Standalone', path: 'standalone' }, + { + id: '/overview', + label: 'Overview', + path: 'overview', + group: 'overview', + }, + { + id: '/techdocs', + label: 'TechDocs', + path: 'techdocs', + group: 'techdocs', + }, + ]; + + const groupDefinitions = { + overview: { title: 'Overview' }, + techdocs: { title: 'TechDocs' }, + }; + + renderInTestApp( + , + ); + + const tabElements = screen.getAllByRole('tab'); + expect(tabElements).toHaveLength(3); + expect(tabElements[0]).toHaveTextContent('Overview'); + expect(tabElements[1]).toHaveTextContent('TechDocs'); + expect(tabElements[2]).toHaveTextContent('Standalone'); + }); +}); + describe('EntityTabs', () => { const subRoutes = [ { From 30e08dfbcc3c4ade49c6c04cc6ef6cd6e1e6fec6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 15:37:10 +0100 Subject: [PATCH 062/118] Add default entity content groups for techdocs and api-docs plugins Signed-off-by: Patrik Oldsberg --- .changeset/entity-content-group-api-docs.md | 5 +++++ .changeset/entity-content-group-techdocs.md | 5 +++++ packages/app/app-config.yaml | 9 +++------ plugins/api-docs/src/alpha.tsx | 2 ++ plugins/techdocs/src/alpha/index.tsx | 1 + 5 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 .changeset/entity-content-group-api-docs.md create mode 100644 .changeset/entity-content-group-techdocs.md diff --git a/.changeset/entity-content-group-api-docs.md b/.changeset/entity-content-group-api-docs.md new file mode 100644 index 0000000000..8eb3e12435 --- /dev/null +++ b/.changeset/entity-content-group-api-docs.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +Added default entity content groups for the API docs entity content tabs. The API definition tab defaults to the `documentation` group and the APIs tab defaults to the `development` group. diff --git a/.changeset/entity-content-group-techdocs.md b/.changeset/entity-content-group-techdocs.md new file mode 100644 index 0000000000..a99992301c --- /dev/null +++ b/.changeset/entity-content-group-techdocs.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Added `documentation` as the default entity content group for the TechDocs entity content tab. diff --git a/packages/app/app-config.yaml b/packages/app/app-config.yaml index 2000e785b3..0fef43fbb5 100644 --- a/packages/app/app-config.yaml +++ b/packages/app/app-config.yaml @@ -97,22 +97,19 @@ app: # - entity-card:azure-devops/readme # Entity page contents - - entity-content:catalog/overview: - config: - group: overview + - entity-content:catalog/overview - entity-content:api-docs/definition - entity-content:api-docs/apis: config: - # example associating with a default group + # example overriding the default group group: documentation icon: kind:api - entity-content:techdocs: config: - group: documentation icon: techdocs - entity-content:kubernetes/kubernetes: config: - # example disassociating with a default group + # example disassociating from the default group group: false # - entity-content:azure-devops/pipelines # - entity-content:azure-devops/pull-requests diff --git a/plugins/api-docs/src/alpha.tsx b/plugins/api-docs/src/alpha.tsx index 0c44d5d51c..4afe97dee9 100644 --- a/plugins/api-docs/src/alpha.tsx +++ b/plugins/api-docs/src/alpha.tsx @@ -174,6 +174,7 @@ const apiDocsDefinitionEntityContent = EntityContentBlueprint.make({ params: { path: '/definition', title: 'Definition', + group: 'documentation', filter: { kind: 'api' }, loader: async () => import('./components/ApiDefinitionCard').then(m => ( @@ -191,6 +192,7 @@ const apiDocsApisEntityContent = EntityContentBlueprint.make({ params: { path: '/apis', title: 'APIs', + group: 'development', filter: { kind: 'component' }, loader: async () => import('./components/ApisCards').then(m => ( diff --git a/plugins/techdocs/src/alpha/index.tsx b/plugins/techdocs/src/alpha/index.tsx index 409bf03269..6acc6b5e6d 100644 --- a/plugins/techdocs/src/alpha/index.tsx +++ b/plugins/techdocs/src/alpha/index.tsx @@ -223,6 +223,7 @@ const techDocsEntityContent = EntityContentBlueprint.makeWithOverrides({ { path: 'docs', title: 'TechDocs', + group: 'documentation', routeRef: rootCatalogDocsRouteRef, loader: () => { // Merge addons from the API with old-style direct attachments From 2fcba39deaf8050fb256fdc9fc0e033df4e10bba Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 11:47:27 +0100 Subject: [PATCH 063/118] Internalize CLI lib utilities into consuming modules Move typeDistProject.ts into the build module, duplicate optionsParser.ts into build and lint modules, inline configOption in the build module, and simplify the deprecated migrate package-exports command to remove cross-module dependencies. Signed-off-by: Patrik Oldsberg --- .changeset/cli-internalize-lib-modules.md | 5 ++ .../modules/build/commands/package/pack.ts | 2 +- .../src/modules/build/commands/repo/build.ts | 2 +- packages/cli/src/modules/build/index.ts | 8 ++- .../__testUtils__/createFeatureEnvironment.ts | 0 .../build/lib/bundler/moduleFederation.ts | 2 +- .../{ => modules/build}/lib/optionsParser.ts | 0 .../build/lib/packager/createDistWorkspace.ts | 2 +- .../build/lib/packager/productionPack.ts | 2 +- .../build}/lib/typeDistProject.test.ts | 0 .../build}/lib/typeDistProject.ts | 0 .../src/modules/lint/commands/repo/lint.ts | 2 +- .../cli/src/modules/lint/lib/optionsParser.ts | 70 +++++++++++++++++++ .../migrate/commands/packageExports.ts | 15 +--- 14 files changed, 89 insertions(+), 21 deletions(-) create mode 100644 .changeset/cli-internalize-lib-modules.md rename packages/cli/src/{ => modules/build}/lib/__testUtils__/createFeatureEnvironment.ts (100%) rename packages/cli/src/{ => modules/build}/lib/optionsParser.ts (100%) rename packages/cli/src/{ => modules/build}/lib/typeDistProject.test.ts (100%) rename packages/cli/src/{ => modules/build}/lib/typeDistProject.ts (100%) create mode 100644 packages/cli/src/modules/lint/lib/optionsParser.ts diff --git a/.changeset/cli-internalize-lib-modules.md b/.changeset/cli-internalize-lib-modules.md new file mode 100644 index 0000000000..f9ef219a3a --- /dev/null +++ b/.changeset/cli-internalize-lib-modules.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Internal refactor to move shared utilities into their consuming modules, reducing cross-module dependencies. diff --git a/packages/cli/src/modules/build/commands/package/pack.ts b/packages/cli/src/modules/build/commands/package/pack.ts index 737b4b6931..67ef7bca8b 100644 --- a/packages/cli/src/modules/build/commands/package/pack.ts +++ b/packages/cli/src/modules/build/commands/package/pack.ts @@ -22,7 +22,7 @@ import { targetPaths } from '@backstage/cli-common'; import fs from 'fs-extra'; import { publishPreflightCheck } from '../../lib/publishing'; -import { createTypeDistProject } from '../../../../lib/typeDistProject'; +import { createTypeDistProject } from '../../../build/lib/typeDistProject'; export const pre = async () => { publishPreflightCheck({ diff --git a/packages/cli/src/modules/build/commands/repo/build.ts b/packages/cli/src/modules/build/commands/repo/build.ts index dd65e926b2..59d0a839e4 100644 --- a/packages/cli/src/modules/build/commands/repo/build.ts +++ b/packages/cli/src/modules/build/commands/repo/build.ts @@ -28,7 +28,7 @@ import { } from '@backstage/cli-node'; import { buildFrontend } from '../../lib/buildFrontend'; import { buildBackend } from '../../lib/buildBackend'; -import { createScriptOptionsParser } from '../../../../lib/optionsParser'; +import { createScriptOptionsParser } from '../../lib/optionsParser'; export async function command(opts: OptionValues, cmd: Command): Promise { let packages = await PackageGraph.listTargetPackages(); diff --git a/packages/cli/src/modules/build/index.ts b/packages/cli/src/modules/build/index.ts index 277993d252..30a9b3a19e 100644 --- a/packages/cli/src/modules/build/index.ts +++ b/packages/cli/src/modules/build/index.ts @@ -17,7 +17,13 @@ import { Command, Option } from 'commander'; import { createCliPlugin } from '../../wiring/factory'; import { lazy } from '../../wiring/lazy'; -import { configOption } from '../config'; + +const configOption = [ + '--config ', + 'Config files to load instead of app-config.yaml', + (opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]), + Array(), +] as const; export function registerPackageCommands(command: Command) { command diff --git a/packages/cli/src/lib/__testUtils__/createFeatureEnvironment.ts b/packages/cli/src/modules/build/lib/__testUtils__/createFeatureEnvironment.ts similarity index 100% rename from packages/cli/src/lib/__testUtils__/createFeatureEnvironment.ts rename to packages/cli/src/modules/build/lib/__testUtils__/createFeatureEnvironment.ts diff --git a/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts b/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts index 307cb5cebc..69e1415b5d 100644 --- a/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts +++ b/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts @@ -20,7 +20,7 @@ import { readEntryPoints } from '../entryPoints'; import { createTypeDistProject, getEntryPointDefaultFeatureType, -} from '../../../../lib/typeDistProject'; +} from '../typeDistProject'; import { BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL, defaultRemoteSharedDependencies, diff --git a/packages/cli/src/lib/optionsParser.ts b/packages/cli/src/modules/build/lib/optionsParser.ts similarity index 100% rename from packages/cli/src/lib/optionsParser.ts rename to packages/cli/src/modules/build/lib/optionsParser.ts diff --git a/packages/cli/src/modules/build/lib/packager/createDistWorkspace.ts b/packages/cli/src/modules/build/lib/packager/createDistWorkspace.ts index 50fb0f4e31..9f63525186 100644 --- a/packages/cli/src/modules/build/lib/packager/createDistWorkspace.ts +++ b/packages/cli/src/modules/build/lib/packager/createDistWorkspace.ts @@ -44,7 +44,7 @@ import { PackageGraphNode, runConcurrentTasks, } from '@backstage/cli-node'; -import { createTypeDistProject } from '../../../../lib/typeDistProject'; +import { createTypeDistProject } from '../typeDistProject'; // These packages aren't safe to pack in parallel since the CLI depends on them const UNSAFE_PACKAGES = [ diff --git a/packages/cli/src/modules/build/lib/packager/productionPack.ts b/packages/cli/src/modules/build/lib/packager/productionPack.ts index ce9f15388c..55eb6edc48 100644 --- a/packages/cli/src/modules/build/lib/packager/productionPack.ts +++ b/packages/cli/src/modules/build/lib/packager/productionPack.ts @@ -19,7 +19,7 @@ import npmPackList from 'npm-packlist'; import { resolve as resolvePath, posix as posixPath } from 'node:path'; import { BackstagePackageJson } from '@backstage/cli-node'; import { readEntryPoints } from '../entryPoints'; -import { getEntryPointDefaultFeatureType } from '../../../../lib/typeDistProject'; +import { getEntryPointDefaultFeatureType } from '../typeDistProject'; import { Project } from 'ts-morph'; const PKG_PATH = 'package.json'; diff --git a/packages/cli/src/lib/typeDistProject.test.ts b/packages/cli/src/modules/build/lib/typeDistProject.test.ts similarity index 100% rename from packages/cli/src/lib/typeDistProject.test.ts rename to packages/cli/src/modules/build/lib/typeDistProject.test.ts diff --git a/packages/cli/src/lib/typeDistProject.ts b/packages/cli/src/modules/build/lib/typeDistProject.ts similarity index 100% rename from packages/cli/src/lib/typeDistProject.ts rename to packages/cli/src/modules/build/lib/typeDistProject.ts diff --git a/packages/cli/src/modules/lint/commands/repo/lint.ts b/packages/cli/src/modules/lint/commands/repo/lint.ts index dafcff45c6..d0b4f6f37e 100644 --- a/packages/cli/src/modules/lint/commands/repo/lint.ts +++ b/packages/cli/src/modules/lint/commands/repo/lint.ts @@ -27,7 +27,7 @@ import { } from '@backstage/cli-node'; import { targetPaths } from '@backstage/cli-common'; -import { createScriptOptionsParser } from '../../../../lib/optionsParser'; +import { createScriptOptionsParser } from '../../lib/optionsParser'; import { SuccessCache } from '../../../../lib/cache/SuccessCache'; function depCount(pkg: BackstagePackageJson) { diff --git a/packages/cli/src/modules/lint/lib/optionsParser.ts b/packages/cli/src/modules/lint/lib/optionsParser.ts new file mode 100644 index 0000000000..45c7eac0aa --- /dev/null +++ b/packages/cli/src/modules/lint/lib/optionsParser.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2024 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 { Command } from 'commander'; + +export function createScriptOptionsParser( + anyCmd: Command, + commandPath: string[], +) { + // Regardless of what command instance is passed in we want to find + // the root command and resolve the path from there + let rootCmd = anyCmd; + while (rootCmd.parent) { + rootCmd = rootCmd.parent; + } + + // Now find the command that was requested + let targetCmd = rootCmd as Command | undefined; + for (const name of commandPath) { + targetCmd = targetCmd?.commands.find(c => c.name() === name) as + | Command + | undefined; + } + + if (!targetCmd) { + throw new Error( + `Could not find package command '${commandPath.join(' ')}'`, + ); + } + const cmd = targetCmd; + + const expectedScript = `backstage-cli ${commandPath.join(' ')}`; + + return (scriptStr?: string) => { + if (!scriptStr || !scriptStr.startsWith(expectedScript)) { + return undefined; + } + + const argsStr = scriptStr.slice(expectedScript.length).trim(); + + // Can't clone or copy or even use commands as prototype, so we mutate + // the necessary members instead, and then reset them once we're done + const currentOpts = (cmd as any)._optionValues; + const currentStore = (cmd as any)._storeOptionsAsProperties; + + const result: Record = {}; + (cmd as any)._storeOptionsAsProperties = false; + (cmd as any)._optionValues = result; + + // Triggers the writing of options to the result object + cmd.parseOptions(argsStr.split(' ')); + + (cmd as any)._storeOptionsAsProperties = currentOpts; + (cmd as any)._optionValues = currentStore; + + return result; + }; +} diff --git a/packages/cli/src/modules/migrate/commands/packageExports.ts b/packages/cli/src/modules/migrate/commands/packageExports.ts index 4eb741ce4d..0d02782509 100644 --- a/packages/cli/src/modules/migrate/commands/packageExports.ts +++ b/packages/cli/src/modules/migrate/commands/packageExports.ts @@ -14,21 +14,8 @@ * limitations under the License. */ -import { - fixPackageExports, - readFixablePackages, - writeFixedPackages, -} from '../../maintenance/commands/repo/fix'; - export async function command() { console.log( - 'The `migrate package-exports` command is deprecated, use `repo fix` instead.', + 'The `migrate package-exports` command has been removed, use `repo fix` instead.', ); - const packages = await readFixablePackages(); - - for (const pkg of packages) { - fixPackageExports(pkg); - } - - await writeFixedPackages(packages); } From b36a60dc45660ebacf0241450f0de808d83d3141 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 14:08:47 +0100 Subject: [PATCH 064/118] Address review feedback Add separate breaking changeset for the removal of `migrate package-exports`, and fix a pre-existing bug where the restore assignments in `createScriptOptionsParser` were swapped. Signed-off-by: Patrik Oldsberg --- .changeset/cli-remove-migrate-package-exports.md | 5 +++++ packages/cli/src/modules/build/lib/optionsParser.ts | 4 ++-- packages/cli/src/modules/lint/lib/optionsParser.ts | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 .changeset/cli-remove-migrate-package-exports.md diff --git a/.changeset/cli-remove-migrate-package-exports.md b/.changeset/cli-remove-migrate-package-exports.md new file mode 100644 index 0000000000..03ad1974d8 --- /dev/null +++ b/.changeset/cli-remove-migrate-package-exports.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': minor +--- + +**BREAKING**: The `migrate package-exports` command has been removed. Use `repo fix` instead. diff --git a/packages/cli/src/modules/build/lib/optionsParser.ts b/packages/cli/src/modules/build/lib/optionsParser.ts index 45c7eac0aa..15561a2e42 100644 --- a/packages/cli/src/modules/build/lib/optionsParser.ts +++ b/packages/cli/src/modules/build/lib/optionsParser.ts @@ -62,8 +62,8 @@ export function createScriptOptionsParser( // Triggers the writing of options to the result object cmd.parseOptions(argsStr.split(' ')); - (cmd as any)._storeOptionsAsProperties = currentOpts; - (cmd as any)._optionValues = currentStore; + (cmd as any)._optionValues = currentOpts; + (cmd as any)._storeOptionsAsProperties = currentStore; return result; }; diff --git a/packages/cli/src/modules/lint/lib/optionsParser.ts b/packages/cli/src/modules/lint/lib/optionsParser.ts index 45c7eac0aa..15561a2e42 100644 --- a/packages/cli/src/modules/lint/lib/optionsParser.ts +++ b/packages/cli/src/modules/lint/lib/optionsParser.ts @@ -62,8 +62,8 @@ export function createScriptOptionsParser( // Triggers the writing of options to the result object cmd.parseOptions(argsStr.split(' ')); - (cmd as any)._storeOptionsAsProperties = currentOpts; - (cmd as any)._optionValues = currentStore; + (cmd as any)._optionValues = currentOpts; + (cmd as any)._storeOptionsAsProperties = currentStore; return result; }; From c85ac861172ae2c32a2a460e7265b464ae01dc22 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 16:11:57 +0100 Subject: [PATCH 065/118] cli: split loadCliConfig into separate build and config implementations Split the shared loadCliConfig function into two separate implementations to remove the cross-module dependency from build to config. The build module's version keeps the watch/streaming capability needed by the dev server, while the config module's version is simplified to one-shot loading since no config commands need watching. Signed-off-by: Patrik Oldsberg --- .changeset/cli-split-loadCliConfig.md | 5 + .../src/modules/build/lib/buildFrontend.ts | 2 +- .../src/modules/build/lib/bundler/server.ts | 2 +- packages/cli/src/modules/build/lib/config.ts | 128 ++++++++++++++++++ packages/cli/src/modules/config/lib/config.ts | 34 +---- 5 files changed, 141 insertions(+), 30 deletions(-) create mode 100644 .changeset/cli-split-loadCliConfig.md create mode 100644 packages/cli/src/modules/build/lib/config.ts diff --git a/.changeset/cli-split-loadCliConfig.md b/.changeset/cli-split-loadCliConfig.md new file mode 100644 index 0000000000..d9c3571fe0 --- /dev/null +++ b/.changeset/cli-split-loadCliConfig.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Internal refactor to split `loadCliConfig` into separate implementations for the build and config CLI modules, removing a cross-module dependency. diff --git a/packages/cli/src/modules/build/lib/buildFrontend.ts b/packages/cli/src/modules/build/lib/buildFrontend.ts index 6a5785a5b9..7ca337cfdb 100644 --- a/packages/cli/src/modules/build/lib/buildFrontend.ts +++ b/packages/cli/src/modules/build/lib/buildFrontend.ts @@ -18,7 +18,7 @@ import fs from 'fs-extra'; import { resolve as resolvePath } from 'node:path'; import { buildBundle, getModuleFederationRemoteOptions } from './bundler'; import { BackstagePackageJson } from '@backstage/cli-node'; -import { loadCliConfig } from '../../config/lib/config'; +import { loadCliConfig } from './config'; interface BuildAppOptions { targetDir: string; diff --git a/packages/cli/src/modules/build/lib/bundler/server.ts b/packages/cli/src/modules/build/lib/bundler/server.ts index 57d6bed052..e474157c7c 100644 --- a/packages/cli/src/modules/build/lib/bundler/server.ts +++ b/packages/cli/src/modules/build/lib/bundler/server.ts @@ -24,7 +24,7 @@ import { RspackDevServer } from '@rspack/dev-server'; import { targetPaths } from '@backstage/cli-common'; -import { loadCliConfig } from '../../../config/lib/config'; +import { loadCliConfig } from '../config'; import { createConfig, resolveBaseUrl, resolveEndpoint } from './config'; import { createDetectedModulesEntryPoint } from './packageDetection'; import { resolveBundlingPaths, resolveOptionalBundlingPaths } from './paths'; diff --git a/packages/cli/src/modules/build/lib/config.ts b/packages/cli/src/modules/build/lib/config.ts new file mode 100644 index 0000000000..56bfc68ced --- /dev/null +++ b/packages/cli/src/modules/build/lib/config.ts @@ -0,0 +1,128 @@ +/* + * Copyright 2020 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 { ConfigSources, loadConfigSchema } from '@backstage/config-loader'; +import { AppConfig, ConfigReader } from '@backstage/config'; +import { targetPaths } from '@backstage/cli-common'; + +import { getPackages } from '@manypkg/get-packages'; +import { PackageGraph } from '@backstage/cli-node'; +import { resolve as resolvePath } from 'node:path'; + +type Options = { + args: string[]; + targetDir?: string; + fromPackage?: string; + withFilteredKeys?: boolean; + watch?: (newFrontendAppConfigs: AppConfig[]) => void; +}; + +export async function loadCliConfig(options: Options) { + const targetDir = options.targetDir ?? targetPaths.dir; + + const { packages } = await getPackages(targetDir); + + let localPackageNames; + if (options.fromPackage) { + if (packages.length) { + const graph = PackageGraph.fromPackages(packages); + localPackageNames = Array.from( + graph.collectPackageNames([options.fromPackage], node => { + // Workaround for Backstage main repo only, since the CLI has some artificial devDependencies + if (node.name === '@backstage/cli') { + return undefined; + } + return node.localDependencies.keys(); + }), + ); + } else { + localPackageNames = [options.fromPackage]; + } + } else { + localPackageNames = packages.map(p => p.packageJson.name); + } + + const schema = await loadConfigSchema({ + dependencies: localPackageNames, + packagePaths: [targetPaths.resolveRoot('package.json')], + }); + + const source = ConfigSources.default({ + allowMissingDefaultConfig: true, + watch: Boolean(options.watch), + rootDir: targetPaths.rootDir, + argv: options.args.flatMap(t => ['--config', resolvePath(targetDir, t)]), + }); + + const appConfigs = await new Promise((resolve, reject) => { + async function loadConfigReaderLoop() { + let loaded = false; + + try { + const abortController = new AbortController(); + for await (const { configs } of source.readConfigData({ + signal: abortController.signal, + })) { + if (loaded) { + const newFrontendAppConfigs = schema.process(configs, { + visibility: ['frontend'], + withFilteredKeys: options.withFilteredKeys, + ignoreSchemaErrors: true, + }); + options.watch?.(newFrontendAppConfigs); + } else { + resolve(configs); + loaded = true; + + if (!options.watch) { + abortController.abort(); + } + } + } + } catch (error) { + if (loaded) { + console.error(`Failed to reload configuration, ${error}`); + } else { + reject(error); + } + } + } + loadConfigReaderLoop(); + }); + + const configurationLoadedMessage = appConfigs.length + ? `Loaded config from ${appConfigs.map(c => c.context).join(', ')}` + : `No configuration files found, running without config`; + + process.stderr.write(`${configurationLoadedMessage}\n`); + + const frontendAppConfigs = schema.process(appConfigs, { + visibility: ['frontend'], + withFilteredKeys: options.withFilteredKeys, + ignoreSchemaErrors: true, + }); + const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs); + + const fullConfig = ConfigReader.fromConfigs(appConfigs); + + return { + schema, + appConfigs, + frontendConfig, + frontendAppConfigs, + fullConfig, + }; +} diff --git a/packages/cli/src/modules/config/lib/config.ts b/packages/cli/src/modules/config/lib/config.ts index 1d920eb31e..763af2174f 100644 --- a/packages/cli/src/modules/config/lib/config.ts +++ b/packages/cli/src/modules/config/lib/config.ts @@ -27,11 +27,9 @@ type Options = { targetDir?: string; fromPackage?: string; mockEnv?: boolean; - withFilteredKeys?: boolean; withDeprecatedKeys?: boolean; fullVisibility?: boolean; strict?: boolean; - watch?: (newFrontendAppConfigs: AppConfig[]) => void; }; export async function loadCliConfig(options: Options) { @@ -73,48 +71,29 @@ export async function loadCliConfig(options: Options) { substitutionFunc: options.mockEnv ? async name => process.env[name] || 'x' : undefined, - watch: Boolean(options.watch), rootDir: targetPaths.rootDir, argv: options.args.flatMap(t => ['--config', resolvePath(targetDir, t)]), }); const appConfigs = await new Promise((resolve, reject) => { - async function loadConfigReaderLoop() { + async function readConfig() { let loaded = false; - try { const abortController = new AbortController(); for await (const { configs } of source.readConfigData({ signal: abortController.signal, })) { - if (loaded) { - const newFrontendAppConfigs = schema.process(configs, { - visibility: options.fullVisibility - ? ['frontend', 'backend', 'secret'] - : ['frontend'], - withFilteredKeys: options.withFilteredKeys, - withDeprecatedKeys: options.withDeprecatedKeys, - ignoreSchemaErrors: !options.strict, - }); - options.watch?.(newFrontendAppConfigs); - } else { - resolve(configs); - loaded = true; - - if (!options.watch) { - abortController.abort(); - } - } + resolve(configs); + loaded = true; + abortController.abort(); } } catch (error) { - if (loaded) { - console.error(`Failed to reload configuration, ${error}`); - } else { + if (!loaded) { reject(error); } } } - loadConfigReaderLoop(); + readConfig(); }); const configurationLoadedMessage = appConfigs.length @@ -130,7 +109,6 @@ export async function loadCliConfig(options: Options) { visibility: options.fullVisibility ? ['frontend', 'backend', 'secret'] : ['frontend'], - withFilteredKeys: options.withFilteredKeys, withDeprecatedKeys: options.withDeprecatedKeys, ignoreSchemaErrors: !options.strict, }); From bd2c923d8e379d5e87f637609559d9ad137eb521 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 17:11:07 +0100 Subject: [PATCH 066/118] Throw error for removed migrate package-exports command Signed-off-by: Patrik Oldsberg --- packages/cli/src/modules/migrate/commands/packageExports.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/modules/migrate/commands/packageExports.ts b/packages/cli/src/modules/migrate/commands/packageExports.ts index 0d02782509..ea42638e58 100644 --- a/packages/cli/src/modules/migrate/commands/packageExports.ts +++ b/packages/cli/src/modules/migrate/commands/packageExports.ts @@ -15,7 +15,7 @@ */ export async function command() { - console.log( + throw new Error( 'The `migrate package-exports` command has been removed, use `repo fix` instead.', ); } From 4868ff07b6ddf644bfafe59fb6234c004f8fcf76 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 17:31:30 +0100 Subject: [PATCH 067/118] Fix typeDistProject import path in pack.ts The file was moved from maintenance to build on master, so the cross-module import is no longer needed. Signed-off-by: Patrik Oldsberg --- packages/cli/src/modules/build/commands/package/pack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/modules/build/commands/package/pack.ts b/packages/cli/src/modules/build/commands/package/pack.ts index 67ef7bca8b..0265c07093 100644 --- a/packages/cli/src/modules/build/commands/package/pack.ts +++ b/packages/cli/src/modules/build/commands/package/pack.ts @@ -22,7 +22,7 @@ import { targetPaths } from '@backstage/cli-common'; import fs from 'fs-extra'; import { publishPreflightCheck } from '../../lib/publishing'; -import { createTypeDistProject } from '../../../build/lib/typeDistProject'; +import { createTypeDistProject } from '../../lib/typeDistProject'; export const pre = async () => { publishPreflightCheck({ From 53268fa77233b25a9c30198f4c229ca66430a64b Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 25 Feb 2026 16:55:08 +0000 Subject: [PATCH 068/118] Add BgReset Signed-off-by: Charles de Dreuille --- .storybook/preview.tsx | 2 +- packages/ui/report.api.md | 4 +- packages/ui/src/components/Dialog/Dialog.tsx | 15 +- packages/ui/src/components/Menu/Menu.tsx | 241 +++++++++--------- .../components/Popover/Popover.stories.tsx | 2 +- .../ui/src/components/Popover/Popover.tsx | 15 +- .../ui/src/components/Tooltip/Tooltip.tsx | 15 +- packages/ui/src/hooks/useBg.tsx | 18 ++ packages/ui/src/types.ts | 5 +- 9 files changed, 177 insertions(+), 140 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index c5074f7749..9734cee295 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -176,7 +176,7 @@ export default definePreview({ : parseInt(selectedBackground.split('-')[1], 10), }).reduce( children => ( - + {children} ), diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 25522f1825..3ca30cd73c 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -76,7 +76,7 @@ export const AccordionDefinition: { readonly propDefs: { readonly bg: { readonly dataAttribute: true; - readonly default: 'neutral-auto'; + readonly default: 'neutral'; }; readonly children: {}; readonly className: {}; @@ -1604,7 +1604,7 @@ export interface PopoverProps extends Omit { } // @public -export type ProviderBg = ContainerBg | 'neutral-auto'; +export type ProviderBg = 'neutral' | 'danger' | 'warning' | 'success'; // @public (undocumented) export interface QueryOptions { diff --git a/packages/ui/src/components/Dialog/Dialog.tsx b/packages/ui/src/components/Dialog/Dialog.tsx index efdc6c4567..0c0f4bf53f 100644 --- a/packages/ui/src/components/Dialog/Dialog.tsx +++ b/packages/ui/src/components/Dialog/Dialog.tsx @@ -34,6 +34,7 @@ import { useStyles } from '../../hooks/useStyles'; import { DialogDefinition } from './definition'; import { Flex } from '../Flex'; import { Box } from '../Box'; +import { BgReset } from '../../hooks/useBg'; import styles from './Dialog.module.css'; /** @public */ @@ -72,12 +73,14 @@ export const Dialog = forwardRef, DialogProps>( ...style, }} > - - {children} - + + + {children} + + ); diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx index da4243f643..a9835b2f68 100644 --- a/packages/ui/src/components/Menu/Menu.tsx +++ b/packages/ui/src/components/Menu/Menu.tsx @@ -59,6 +59,7 @@ import { import styles from './Menu.module.css'; import clsx from 'clsx'; import { Box } from '../Box'; +import { BgReset } from '../../hooks/useBg'; const { RoutingProvider, useRoutingRegistrationEffect } = createRoutingRegistration(); @@ -120,23 +121,25 @@ export const Menu = (props: MenuProps) => { )} placement={placement} > - - {virtualized ? ( - - {menuContent} - - ) : ( - menuContent - )} - + + + {virtualized ? ( + + {menuContent} + + ) : ( + menuContent + )} + + ); @@ -175,23 +178,25 @@ export const MenuListBox = (props: MenuListBoxProps) => { )} placement={placement} > - - {virtualized ? ( - - {listBoxContent} - - ) : ( - listBoxContent - )} - + + + {virtualized ? ( + + {listBoxContent} + + ) : ( + listBoxContent + )} + + ); }; @@ -230,48 +235,50 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps) => { )} placement={placement} > - - - + + + + + + + + + {virtualized ? ( + + {menuContent} + + ) : ( + menuContent )} - aria-label={props.placeholder || 'Search'} - > - - - - - - {virtualized ? ( - - {menuContent} - - ) : ( - menuContent - )} - - + + + ); @@ -314,48 +321,50 @@ export const MenuAutocompleteListbox = ( )} placement={placement} > - - - + + + + + + + + + {virtualized ? ( + + {listBoxContent} + + ) : ( + listBoxContent )} - aria-label={props.placeholder || 'Search'} - > - - - - - - {virtualized ? ( - - {listBoxContent} - - ) : ( - listBoxContent - )} - - + + + ); }; diff --git a/packages/ui/src/components/Popover/Popover.stories.tsx b/packages/ui/src/components/Popover/Popover.stories.tsx index e05b352fc9..0a0e68b572 100644 --- a/packages/ui/src/components/Popover/Popover.stories.tsx +++ b/packages/ui/src/components/Popover/Popover.stories.tsx @@ -208,7 +208,7 @@ export const WithRichContent = Default.extend({ This is a popover with rich content. It can contain multiple elements and formatted text. - + You can also use the automatic bg system inside it. diff --git a/packages/ui/src/components/Popover/Popover.tsx b/packages/ui/src/components/Popover/Popover.tsx index ad8a203558..ed13328a94 100644 --- a/packages/ui/src/components/Popover/Popover.tsx +++ b/packages/ui/src/components/Popover/Popover.tsx @@ -23,6 +23,7 @@ import { useStyles } from '../../hooks/useStyles'; import { PopoverDefinition } from './definition'; import styles from './Popover.module.css'; import { Box } from '../Box'; +import { BgReset } from '../../hooks/useBg'; /** * A popover component built on React Aria Components that displays floating @@ -95,12 +96,14 @@ export const Popover = forwardRef( )} - - {children} - + + + {children} + + )} diff --git a/packages/ui/src/components/Tooltip/Tooltip.tsx b/packages/ui/src/components/Tooltip/Tooltip.tsx index a64c8c072d..d7fb97005b 100644 --- a/packages/ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/ui/src/components/Tooltip/Tooltip.tsx @@ -28,6 +28,7 @@ import { useStyles } from '../../hooks/useStyles'; import { TooltipDefinition } from './definition'; import styles from './Tooltip.module.css'; import { Box } from '../Box'; +import { BgReset } from '../../hooks/useBg'; /** @public */ export const TooltipTrigger = (props: TooltipTriggerComponentProps) => { @@ -71,12 +72,14 @@ export const Tooltip = forwardRef( - - {children} - + + + {children} + + ); }, diff --git a/packages/ui/src/hooks/useBg.tsx b/packages/ui/src/hooks/useBg.tsx index ea9376b039..e67c47da49 100644 --- a/packages/ui/src/hooks/useBg.tsx +++ b/packages/ui/src/hooks/useBg.tsx @@ -69,6 +69,24 @@ export const BgProvider = ({ bg, children }: BgProviderProps) => { ); }; +/** + * Resets the bg context to undefined, cutting any inherited neutral chain. + * Use this inside overlay components (Popover, Tooltip, Dialog, Menu) so + * their content always starts from neutral-1 regardless of where the trigger + * is placed in the tree. + * + * @internal + */ +export const BgReset = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; + /** * Hook for consumer components (e.g. Button) to read the parent bg context. * diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 05ef25d5dc..af87bfb20b 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -190,9 +190,10 @@ export interface ComponentDefinition { * The 'neutral-4' level is not exposed as a prop value -- it is reserved * for leaf component CSS (e.g. Button on a 'neutral-3' surface). * - * This type is internal. Use `ProviderBg` for component prop types. + * This is the resolved/internal representation used by the bg context system. + * For the prop type accepted by container components, use `ProviderBg` instead. * - * @internal + * @public */ export type ContainerBg = | 'neutral-1' From 768f09d49c3372fd691bdd7dd697aa0d739c6598 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 25 Feb 2026 17:01:55 +0000 Subject: [PATCH 069/118] Create dark-snakes-nail.md Signed-off-by: Charles de Dreuille --- .changeset/dark-snakes-nail.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/dark-snakes-nail.md diff --git a/.changeset/dark-snakes-nail.md b/.changeset/dark-snakes-nail.md new file mode 100644 index 0000000000..d4eb6f0dd2 --- /dev/null +++ b/.changeset/dark-snakes-nail.md @@ -0,0 +1,21 @@ +--- +'@backstage/ui': minor +--- + +**BREAKING**: Simplified the neutral background prop API for container components. The explicit `neutral-1`, `neutral-2`, `neutral-3`, and `neutral-auto` values have been removed from `ProviderBg`. They are replaced by a single `'neutral'` value that always auto-increments from the parent context, making it impossible to skip or pin to an explicit neutral level. + +**Migration:** + +Replace any explicit `bg="neutral-1"`, `bg="neutral-2"`, `bg="neutral-3"`, or `bg="neutral-auto"` props with `bg="neutral"`. To achieve a specific neutral level in stories or tests, use nested containers — each additional `bg="neutral"` wrapper increments by one level. + +```tsx +// Before +... + +// After + + ... + +``` + +**Affected components:** Box, Flex, Grid, Card, Accordion, Popover, Tooltip, Dialog, Menu From f0974f3e6020ccb0b151e8d22203e0e77403b42e Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Wed, 25 Feb 2026 12:03:21 -0600 Subject: [PATCH 070/118] Blog - Get a jump on ContribFest Signed-off-by: Andre Wanlin --- .../2026-02-25-get-a-jump-on-contribfest.mdx | 48 ++++++++++++++++++ ...age-contribfest-kubecon-web-app-header.png | Bin 0 -> 277104 bytes 2 files changed, 48 insertions(+) create mode 100644 microsite/blog/2026-02-25-get-a-jump-on-contribfest.mdx create mode 100644 microsite/blog/assets/2026-02-25/backstage-contribfest-kubecon-web-app-header.png diff --git a/microsite/blog/2026-02-25-get-a-jump-on-contribfest.mdx b/microsite/blog/2026-02-25-get-a-jump-on-contribfest.mdx new file mode 100644 index 0000000000..7e4eacab50 --- /dev/null +++ b/microsite/blog/2026-02-25-get-a-jump-on-contribfest.mdx @@ -0,0 +1,48 @@ +--- +title: 'Get a jump on ContribFest with the new web app' +author: Elaine de Mattos Silva Bezerra, DB Systel GmbH, Heikki Hellgren, OP Financial Group & André Wanlin, Spotify +--- + +![Get a jump on ContribFest with the new web app](assets/2026-02-25/backstage-contribfest-kubecon-web-app-header.png) + +Become a Contrib Champ and join us at ContribFest, where commits become legendary! + +We are once again hosting ContribFest at KubeCon + CloudNativeCon. This time around, it's taking place in Amsterdam on March 26, 2026, at 13:45 CET — make sure to [add it to your schedule](https://kccnceu2026.sched.com/event/2EF7v/contribfest-supercharge-your-open-source-impact-backstage-contribfest-live-andre-wanlin-emma-indal-spotify-heikki-hellgren-op-financial-group-elaine-bezerra-db-systel-gmbh?iframe=no). Learn more about what to expect below and get started now by exploring the new [ContribFest web app](https://contribfest.backstage.io/). + +{/* truncate */} + +## Introducing the ContribFest web app + +We're excited to announce the new ContribFest web app: [https://contribfest.backstage.io/](https://contribfest.backstage.io/). The app simplifies local setup and helps you quickly find good issues to work on from the curated list pre-selected by your ContribFest co-hosts. + +You'll see that the app is broken down into five sections: + +* [Welcome](https://contribfest.backstage.io/): This is where you'll find links to all the things, including the session's slide deck, assignment sheet, the Backstage and Community Plugins repositories, and their respective contribution guides. +* [Getting Started](https://contribfest.backstage.io/getting-started/): Whether you are new to Backstage or an old hat, use this handy checklist to help you get your local environment set up for contributing, including all the commands. (Make sure you check all the boxes, you never know what might happen! 😉) +* [Curated Issues](https://contribfest.backstage.io/issues/): This is what you come to the session for: finding an issue that speaks to you and contributing towards it. This section has a list of issues that we've curated — and filters, so you can slice and dice the list to find the perfect issue to work on. +* [Contrib Champs](https://contribfest.backstage.io/contrib-champs/): We've hosted three other ContribFests in the past — this is where you'll find merged PRs from those sessions, a place to celebrate contributions. Make sure to tag your PRs with “ContribFest”, and maybe your name will show up here one day, too! 🏆 +* [Hall of Hosts](https://contribfest.backstage.io/hall-of-hosts/): ContribFest would not take place without the various community members who have stepped up to help co-host the sessions. This is where you'll see an honor roll of past co-hosts. 🙏 + +## About those Contrib Champs + +The goals of the Backstage ContribFest sessions are many — foster community, work with experts, etc. — but it's pretty obvious that contributions are the most important. It's in the name after all. Here are a few past contributions that we wanted to share to give you an idea of what that looks like: + +* [#27694](https://github.com/backstage/backstage/pull/27694) by [hyb175](https://github.com/hyb175) — Add Pagination to Tech Docs Table: for those with lots of entities with TechDocs, this is a massive performance improvement. +* [#29470](https://github.com/backstage/backstage/pull/29470) by [ioboi](https://github.com/ioboi) — Openshift Auth provider: this allows those using OpenShift to use it to sign into their Backstage instance. +* [#31770](https://github.com/backstage/backstage/pull/31770) by [theZMC](https://github.com/theZMC) — Render HTML in GitHub-flavored Markdown: with this change in place, HTML will now render correctly in the MarkdownContent component when you are using the GitHub-flavored Markdown mode. + +Check out the [Contrib Champs page](https://contribfest.backstage.io/contrib-champs/) to see the full list! + +## Using Dev Containers + +Along with the new ContribFest web app, we are also looking to use Dev Containers this time around to help streamline the session for those who'd like to use that option to get started. On the [Getting Started page](https://contribfest.backstage.io/getting-started/), pick the Dev Containers radio button and then follow the checklist. To give you a quick preview, you'll need to have the following installed: + +* Git, you'll need this to be able to pull down the code +* Docker Desktop (or Docker Engine on Linux) +* VS Code with the Dev Containers extension or IntelliJ IDEA Ultimate + +Check out our [Dev Containers tutorial](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/devcontainer.md) for a deeper dive into the subject. + +## Amsterdam, here we come! + +On behalf of the Backstage ContribFest co-host team, thank you for following along. We're looking forward to meeting you in Amsterdam and working together on your contributions. Please be sure to introduce yourself! \ No newline at end of file diff --git a/microsite/blog/assets/2026-02-25/backstage-contribfest-kubecon-web-app-header.png b/microsite/blog/assets/2026-02-25/backstage-contribfest-kubecon-web-app-header.png new file mode 100644 index 0000000000000000000000000000000000000000..21e726aeb7c4d8521e4332624ede47609db8a5b7 GIT binary patch literal 277104 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 ^|1ODd^9oTZId2EclHwB+7WP&NVf#GR*D9n` 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%0AEfy)i? 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�3iOkoi3v3}V5{oiYnBYc}J*A?E!GU5opc56wW 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≪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≠f4wvralqZ*ShVmG@fJ!uKsOuxQ7e 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


~SnLI<1LXSB}&kOdLs`F8^ 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

00(Smv0S5U*)=poJ(L3E^!IeWG_LWK1hd4I) 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 Date: Wed, 25 Feb 2026 11:52:36 +0100 Subject: [PATCH 071/118] cli-node, cli: move yarnPlugin and SuccessCache to cli-node Move `getHasYarnPlugin` and `SuccessCache` from `@backstage/cli` internal modules to `@backstage/cli-node` as public exports, making them available for reuse by other CLI tooling. Signed-off-by: Patrik Oldsberg --- .changeset/cli-use-cli-node-utils.md | 5 +++++ .changeset/move-utils-to-cli-node.md | 5 +++++ packages/cli-node/package.json | 1 + packages/cli-node/report.api.md | 13 +++++++++++++ .../lib => cli-node/src}/cache/SuccessCache.ts | 7 ++++++- packages/cli-node/src/cache/index.ts | 17 +++++++++++++++++ packages/cli-node/src/index.ts | 4 +++- packages/cli-node/src/yarn/index.ts | 17 +++++++++++++++++ .../src/yarn}/yarnPlugin.test.ts | 0 .../src/lib => cli-node/src/yarn}/yarnPlugin.ts | 2 +- .../cli/src/modules/lint/commands/repo/lint.ts | 2 +- .../modules/migrate/commands/versions/bump.ts | 7 +++++-- .../new/lib/execution/PortableTemplater.ts | 2 +- .../cli/src/modules/test/commands/repo/test.ts | 2 +- yarn.lock | 1 + 15 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 .changeset/cli-use-cli-node-utils.md create mode 100644 .changeset/move-utils-to-cli-node.md rename packages/{cli/src/lib => cli-node/src}/cache/SuccessCache.ts (95%) create mode 100644 packages/cli-node/src/cache/index.ts create mode 100644 packages/cli-node/src/yarn/index.ts rename packages/{cli/src/lib => cli-node/src/yarn}/yarnPlugin.test.ts (100%) rename packages/{cli/src/lib => cli-node/src/yarn}/yarnPlugin.ts (96%) diff --git a/.changeset/cli-use-cli-node-utils.md b/.changeset/cli-use-cli-node-utils.md new file mode 100644 index 0000000000..6b1d6be92d --- /dev/null +++ b/.changeset/cli-use-cli-node-utils.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Migrated `getHasYarnPlugin` and `SuccessCache` to use the implementations from `@backstage/cli-node`. diff --git a/.changeset/move-utils-to-cli-node.md b/.changeset/move-utils-to-cli-node.md new file mode 100644 index 0000000000..cf91e0b54f --- /dev/null +++ b/.changeset/move-utils-to-cli-node.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-node': patch +--- + +Added `getHasYarnPlugin` and `SuccessCache` exports, moved from `@backstage/cli`. diff --git a/packages/cli-node/package.json b/packages/cli-node/package.json index 014ed56992..16cfba3c0d 100644 --- a/packages/cli-node/package.json +++ b/packages/cli-node/package.json @@ -39,6 +39,7 @@ "@yarnpkg/parsers": "^3.0.0", "fs-extra": "^11.2.0", "semver": "^7.5.3", + "yaml": "^2.0.0", "zod": "^3.25.76" }, "devDependencies": { diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index cf27d92894..372bf34206 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -93,6 +93,9 @@ export type ConcurrentTasksOptions = { worker: (item: TItem) => Promise; }; +// @public +export function getHasYarnPlugin(): Promise; + // @public export class GitUtils { static listChangedFiles(ref: string): Promise; @@ -221,6 +224,16 @@ export function runWorkerQueueThreads( results: TResult[]; }>; +// @public +export class SuccessCache { + constructor(name: string, basePath?: string); + // (undocumented) + read(): Promise>; + static trimPaths(input: string): string; + // (undocumented) + write(newEntries: Iterable): Promise; +} + // @public export type WorkerQueueThreadsOptions = { items: Iterable; diff --git a/packages/cli/src/lib/cache/SuccessCache.ts b/packages/cli-node/src/cache/SuccessCache.ts similarity index 95% rename from packages/cli/src/lib/cache/SuccessCache.ts rename to packages/cli-node/src/cache/SuccessCache.ts index 8131a469a4..ba5c07ea30 100644 --- a/packages/cli/src/lib/cache/SuccessCache.ts +++ b/packages/cli-node/src/cache/SuccessCache.ts @@ -22,6 +22,12 @@ const DEFAULT_CACHE_BASE_PATH = 'node_modules/.cache/backstage-cli'; const CACHE_MAX_AGE_MS = 7 * 24 * 3600_000; +/** + * A file-system-based cache that tracks successful operations by storing + * timestamped marker files. + * + * @public + */ export class SuccessCache { readonly #path: string; @@ -89,7 +95,6 @@ export class SuccessCache { const empty = Buffer.alloc(0); for (const key of newEntries) { - // Remove any existing items with the key we're about to add const trimmedItems = existingItems.filter(item => item.endsWith(`_${key}`), ); diff --git a/packages/cli-node/src/cache/index.ts b/packages/cli-node/src/cache/index.ts new file mode 100644 index 0000000000..53c9f0e4e4 --- /dev/null +++ b/packages/cli-node/src/cache/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 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. + */ + +export { SuccessCache } from './SuccessCache'; diff --git a/packages/cli-node/src/index.ts b/packages/cli-node/src/index.ts index 5540666a05..35074507c4 100644 --- a/packages/cli-node/src/index.ts +++ b/packages/cli-node/src/index.ts @@ -20,7 +20,9 @@ * @packageDocumentation */ +export * from './cache'; +export * from './concurrency'; export * from './git'; export * from './monorepo'; -export * from './concurrency'; export * from './roles'; +export * from './yarn'; diff --git a/packages/cli-node/src/yarn/index.ts b/packages/cli-node/src/yarn/index.ts new file mode 100644 index 0000000000..21b2efca3a --- /dev/null +++ b/packages/cli-node/src/yarn/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 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. + */ + +export { getHasYarnPlugin } from './yarnPlugin'; diff --git a/packages/cli/src/lib/yarnPlugin.test.ts b/packages/cli-node/src/yarn/yarnPlugin.test.ts similarity index 100% rename from packages/cli/src/lib/yarnPlugin.test.ts rename to packages/cli-node/src/yarn/yarnPlugin.test.ts diff --git a/packages/cli/src/lib/yarnPlugin.ts b/packages/cli-node/src/yarn/yarnPlugin.ts similarity index 96% rename from packages/cli/src/lib/yarnPlugin.ts rename to packages/cli-node/src/yarn/yarnPlugin.ts index 0390345651..5be89704ea 100644 --- a/packages/cli/src/lib/yarnPlugin.ts +++ b/packages/cli-node/src/yarn/yarnPlugin.ts @@ -33,12 +33,12 @@ const yarnRcSchema = z.object({ * Detects whether the Backstage Yarn plugin is installed in the target repository. * * @returns Promise - true if the plugin is installed, false otherwise + * @public */ export async function getHasYarnPlugin(): Promise { const yarnRcPath = targetPaths.resolveRoot('.yarnrc.yml'); const yarnRcContent = await fs.readFile(yarnRcPath, 'utf-8').catch(e => { if (e.code === 'ENOENT') { - // gracefully continue in case the file doesn't exist return ''; } throw e; diff --git a/packages/cli/src/modules/lint/commands/repo/lint.ts b/packages/cli/src/modules/lint/commands/repo/lint.ts index d0b4f6f37e..ad560935cd 100644 --- a/packages/cli/src/modules/lint/commands/repo/lint.ts +++ b/packages/cli/src/modules/lint/commands/repo/lint.ts @@ -28,7 +28,7 @@ import { import { targetPaths } from '@backstage/cli-common'; import { createScriptOptionsParser } from '../../lib/optionsParser'; -import { SuccessCache } from '../../../../lib/cache/SuccessCache'; +import { SuccessCache } from '@backstage/cli-node'; function depCount(pkg: BackstagePackageJson) { const deps = pkg.dependencies ? Object.keys(pkg.dependencies).length : 0; diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.ts b/packages/cli/src/modules/migrate/commands/versions/bump.ts index 7ecd908d8a..943bd3ab71 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.ts @@ -30,8 +30,11 @@ import { OptionValues } from 'commander'; import { isError, NotFoundError } from '@backstage/errors'; import { resolve as resolvePath } from 'node:path'; -import { getHasYarnPlugin } from '../../../../lib/yarnPlugin'; -import { Lockfile, runConcurrentTasks } from '@backstage/cli-node'; +import { + getHasYarnPlugin, + Lockfile, + runConcurrentTasks, +} from '@backstage/cli-node'; import { fetchPackageInfo, mapDependencies, diff --git a/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts b/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts index 45478957ac..bc8ff19307 100644 --- a/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts +++ b/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts @@ -28,7 +28,7 @@ import { Lockfile } from '@backstage/cli-node'; import { targetPaths } from '@backstage/cli-common'; import { createPackageVersionProvider } from '../../../../lib/version'; -import { getHasYarnPlugin } from '../../../../lib/yarnPlugin'; +import { getHasYarnPlugin } from '@backstage/cli-node'; const builtInHelpers = { camelCase, diff --git a/packages/cli/src/modules/test/commands/repo/test.ts b/packages/cli/src/modules/test/commands/repo/test.ts index 1f57279935..2e4095f88a 100644 --- a/packages/cli/src/modules/test/commands/repo/test.ts +++ b/packages/cli/src/modules/test/commands/repo/test.ts @@ -31,7 +31,7 @@ import { findOwnPaths, isChildPath, } from '@backstage/cli-common'; -import { SuccessCache } from '../../../../lib/cache/SuccessCache'; +import { SuccessCache } from '@backstage/cli-node'; type JestProject = { displayName: string; diff --git a/yarn.lock b/yarn.lock index 74484a0056..1447977846 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3277,6 +3277,7 @@ __metadata: "@yarnpkg/parsers": "npm:^3.0.0" fs-extra: "npm:^11.2.0" semver: "npm:^7.5.3" + yaml: "npm:^2.0.0" zod: "npm:^3.25.76" languageName: unknown linkType: soft From 968570bbb54189349c106a84f1186eeba6bb80db Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 12:46:55 +0100 Subject: [PATCH 072/118] Address review feedback - Remove cli changeset, piggy-back on existing ones - Rename getHasYarnPlugin -> hasYarnPlugin(workspaceDir?) - Make SuccessCache constructor private, add static create() - Consolidate duplicate @backstage/cli-node imports Signed-off-by: Patrik Oldsberg --- .changeset/cli-use-cli-node-utils.md | 5 ---- .changeset/move-utils-to-cli-node.md | 2 +- packages/cli-node/report.api.md | 9 +++--- packages/cli-node/src/cache/SuccessCache.ts | 6 +++- packages/cli-node/src/yarn/index.ts | 2 +- packages/cli-node/src/yarn/yarnPlugin.test.ts | 29 ++++++++++--------- packages/cli-node/src/yarn/yarnPlugin.ts | 13 ++++++--- .../src/modules/lint/commands/repo/lint.ts | 4 +-- .../modules/migrate/commands/versions/bump.ts | 12 ++++---- .../new/lib/execution/PortableTemplater.ts | 6 ++-- .../src/modules/test/commands/repo/test.ts | 5 ++-- 11 files changed, 49 insertions(+), 44 deletions(-) delete mode 100644 .changeset/cli-use-cli-node-utils.md diff --git a/.changeset/cli-use-cli-node-utils.md b/.changeset/cli-use-cli-node-utils.md deleted file mode 100644 index 6b1d6be92d..0000000000 --- a/.changeset/cli-use-cli-node-utils.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Migrated `getHasYarnPlugin` and `SuccessCache` to use the implementations from `@backstage/cli-node`. diff --git a/.changeset/move-utils-to-cli-node.md b/.changeset/move-utils-to-cli-node.md index cf91e0b54f..7c14536ccf 100644 --- a/.changeset/move-utils-to-cli-node.md +++ b/.changeset/move-utils-to-cli-node.md @@ -2,4 +2,4 @@ '@backstage/cli-node': patch --- -Added `getHasYarnPlugin` and `SuccessCache` exports, moved from `@backstage/cli`. +Added `hasYarnPlugin` and `SuccessCache` exports, moved from `@backstage/cli`. diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index 372bf34206..187f56640c 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -93,15 +93,15 @@ export type ConcurrentTasksOptions = { worker: (item: TItem) => Promise; }; -// @public -export function getHasYarnPlugin(): Promise; - // @public export class GitUtils { static listChangedFiles(ref: string): Promise; static readFileAtRef(path: string, ref: string): Promise; } +// @public +export function hasYarnPlugin(workspaceDir?: string): Promise; + // @public export function isMonoRepo(): Promise; @@ -226,7 +226,8 @@ export function runWorkerQueueThreads( // @public export class SuccessCache { - constructor(name: string, basePath?: string); + // (undocumented) + static create(name: string, basePath?: string): SuccessCache; // (undocumented) read(): Promise>; static trimPaths(input: string): string; diff --git a/packages/cli-node/src/cache/SuccessCache.ts b/packages/cli-node/src/cache/SuccessCache.ts index ba5c07ea30..62bef34137 100644 --- a/packages/cli-node/src/cache/SuccessCache.ts +++ b/packages/cli-node/src/cache/SuccessCache.ts @@ -40,7 +40,11 @@ export class SuccessCache { return input.replaceAll(targetPaths.rootDir, ''); } - constructor(name: string, basePath?: string) { + static create(name: string, basePath?: string): SuccessCache { + return new SuccessCache(name, basePath); + } + + private constructor(name: string, basePath?: string) { this.#path = resolvePath(basePath ?? DEFAULT_CACHE_BASE_PATH, name); } diff --git a/packages/cli-node/src/yarn/index.ts b/packages/cli-node/src/yarn/index.ts index 21b2efca3a..ce4eb4231c 100644 --- a/packages/cli-node/src/yarn/index.ts +++ b/packages/cli-node/src/yarn/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { getHasYarnPlugin } from './yarnPlugin'; +export { hasYarnPlugin } from './yarnPlugin'; diff --git a/packages/cli-node/src/yarn/yarnPlugin.test.ts b/packages/cli-node/src/yarn/yarnPlugin.test.ts index a07c2a2ac6..aba1dda579 100644 --- a/packages/cli-node/src/yarn/yarnPlugin.test.ts +++ b/packages/cli-node/src/yarn/yarnPlugin.test.ts @@ -16,12 +16,12 @@ import { createMockDirectory } from '@backstage/backend-test-utils'; import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; -import { getHasYarnPlugin } from './yarnPlugin'; +import { hasYarnPlugin } from './yarnPlugin'; const mockDir = createMockDirectory(); overrideTargetPaths(mockDir.path); -describe('getHasYarnPlugin', () => { +describe('hasYarnPlugin', () => { beforeEach(() => { mockDir.clear(); }); @@ -29,7 +29,7 @@ describe('getHasYarnPlugin', () => { it('should return false when .yarnrc.yml does not exist', async () => { mockDir.setContent({}); - const result = await getHasYarnPlugin(); + const result = await hasYarnPlugin(); expect(result).toBe(false); }); @@ -38,7 +38,7 @@ describe('getHasYarnPlugin', () => { '.yarnrc.yml': '', }); - const result = await getHasYarnPlugin(); + const result = await hasYarnPlugin(); expect(result).toBe(false); }); @@ -47,7 +47,7 @@ describe('getHasYarnPlugin', () => { '.yarnrc.yml': 'plugins: []', }); - const result = await getHasYarnPlugin(); + const result = await hasYarnPlugin(); expect(result).toBe(false); }); @@ -60,7 +60,7 @@ plugins: `, }); - const result = await getHasYarnPlugin(); + const result = await hasYarnPlugin(); expect(result).toBe(false); }); @@ -74,7 +74,7 @@ plugins: `, }); - const result = await getHasYarnPlugin(); + const result = await hasYarnPlugin(); expect(result).toBe(true); }); @@ -86,7 +86,7 @@ plugins: `, }); - const result = await getHasYarnPlugin(); + const result = await hasYarnPlugin(); expect(result).toBe(true); }); @@ -95,7 +95,7 @@ plugins: '.yarnrc.yml': 'invalid: yaml: content: [', }); - await expect(getHasYarnPlugin()).rejects.toThrow(); + await expect(hasYarnPlugin()).rejects.toThrow(); }); it('should throw error when .yarnrc.yml has unexpected structure', async () => { @@ -105,21 +105,22 @@ plugins: "not an array" `, }); - await expect(getHasYarnPlugin()).rejects.toThrow( + await expect(hasYarnPlugin()).rejects.toThrow( 'Unexpected content in .yarnrc.yml', ); }); - it('should handle plugins with different structure', async () => { + it('should resolve from a custom workspace directory', async () => { mockDir.setContent({ - '.yarnrc.yml': ` + 'custom-dir': { + '.yarnrc.yml': ` plugins: - path: .yarn/plugins/@yarnpkg/plugin-backstage.cjs - - path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs `, + }, }); - const result = await getHasYarnPlugin(); + const result = await hasYarnPlugin(mockDir.resolve('custom-dir')); expect(result).toBe(true); }); }); diff --git a/packages/cli-node/src/yarn/yarnPlugin.ts b/packages/cli-node/src/yarn/yarnPlugin.ts index 5be89704ea..87a3b98973 100644 --- a/packages/cli-node/src/yarn/yarnPlugin.ts +++ b/packages/cli-node/src/yarn/yarnPlugin.ts @@ -15,6 +15,7 @@ */ import fs from 'fs-extra'; +import { resolve as resolvePath } from 'node:path'; import yaml from 'yaml'; import z from 'zod'; import { targetPaths } from '@backstage/cli-common'; @@ -30,13 +31,17 @@ const yarnRcSchema = z.object({ }); /** - * Detects whether the Backstage Yarn plugin is installed in the target repository. + * Detects whether the Backstage Yarn plugin is installed in the given workspace directory. * - * @returns Promise - true if the plugin is installed, false otherwise + * @param workspaceDir - The workspace root directory to check. Defaults to the target root. + * @returns Promise resolving to true if the plugin is installed, false otherwise * @public */ -export async function getHasYarnPlugin(): Promise { - const yarnRcPath = targetPaths.resolveRoot('.yarnrc.yml'); +export async function hasYarnPlugin(workspaceDir?: string): Promise { + const yarnRcPath = resolvePath( + workspaceDir ?? targetPaths.rootDir, + '.yarnrc.yml', + ); const yarnRcContent = await fs.readFile(yarnRcPath, 'utf-8').catch(e => { if (e.code === 'ENOENT') { return ''; diff --git a/packages/cli/src/modules/lint/commands/repo/lint.ts b/packages/cli/src/modules/lint/commands/repo/lint.ts index ad560935cd..4351725d57 100644 --- a/packages/cli/src/modules/lint/commands/repo/lint.ts +++ b/packages/cli/src/modules/lint/commands/repo/lint.ts @@ -24,11 +24,11 @@ import { BackstagePackageJson, Lockfile, runWorkerQueueThreads, + SuccessCache, } from '@backstage/cli-node'; import { targetPaths } from '@backstage/cli-common'; import { createScriptOptionsParser } from '../../lib/optionsParser'; -import { SuccessCache } from '@backstage/cli-node'; function depCount(pkg: BackstagePackageJson) { const deps = pkg.dependencies ? Object.keys(pkg.dependencies).length : 0; @@ -41,7 +41,7 @@ function depCount(pkg: BackstagePackageJson) { export async function command(opts: OptionValues, cmd: Command): Promise { let packages = await PackageGraph.listTargetPackages(); - const cache = new SuccessCache('lint', opts.successCacheDir); + const cache = SuccessCache.create('lint', opts.successCacheDir); const cacheContext = opts.successCache ? { entries: await cache.read(), diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.ts b/packages/cli/src/modules/migrate/commands/versions/bump.ts index 943bd3ab71..4b15da4676 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.ts @@ -31,7 +31,7 @@ import { isError, NotFoundError } from '@backstage/errors'; import { resolve as resolvePath } from 'node:path'; import { - getHasYarnPlugin, + hasYarnPlugin, Lockfile, runConcurrentTasks, } from '@backstage/cli-node'; @@ -76,7 +76,7 @@ function extendsDefaultPattern(pattern: string): boolean { export default async (opts: OptionValues) => { const lockfilePath = targetPaths.resolveRoot('yarn.lock'); const lockfile = await Lockfile.load(lockfilePath); - const hasYarnPlugin = await getHasYarnPlugin(); + const yarnPluginEnabled = await hasYarnPlugin(); let pattern = opts.pattern; @@ -130,7 +130,7 @@ export default async (opts: OptionValues) => { }); } - if (hasYarnPlugin) { + if (yarnPluginEnabled) { console.log(); console.log( `Updating yarn plugin to v${releaseManifest.releaseVersion}...`, @@ -214,7 +214,7 @@ export default async (opts: OptionValues) => { const oldLockfileRange = await asLockfileVersion(oldRange); const useBackstageRange = - hasYarnPlugin && + yarnPluginEnabled && // Only use backstage:^ versions if the package is present in // the manifest for the release we're bumping to. releaseManifest.packages.find( @@ -254,7 +254,7 @@ export default async (opts: OptionValues) => { if (extendsDefaultPattern(pattern)) { await bumpBackstageJsonVersion( releaseManifest.releaseVersion, - hasYarnPlugin, + yarnPluginEnabled, ); } else { console.log( @@ -319,7 +319,7 @@ export default async (opts: OptionValues) => { console.log(); } - if (hasYarnPlugin) { + if (yarnPluginEnabled) { console.log(); console.log( chalk.blue( diff --git a/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts b/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts index bc8ff19307..2c94e3639d 100644 --- a/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts +++ b/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts @@ -28,7 +28,7 @@ import { Lockfile } from '@backstage/cli-node'; import { targetPaths } from '@backstage/cli-common'; import { createPackageVersionProvider } from '../../../../lib/version'; -import { getHasYarnPlugin } from '@backstage/cli-node'; +import { hasYarnPlugin } from '@backstage/cli-node'; const builtInHelpers = { camelCase, @@ -55,9 +55,9 @@ export class PortableTemplater { /* ignored */ } - const hasYarnPlugin = await getHasYarnPlugin(); + const yarnPluginEnabled = await hasYarnPlugin(); const versionProvider = createPackageVersionProvider(lockfile, { - preferBackstageProtocol: hasYarnPlugin, + preferBackstageProtocol: yarnPluginEnabled, }); const templater = new PortableTemplater( diff --git a/packages/cli/src/modules/test/commands/repo/test.ts b/packages/cli/src/modules/test/commands/repo/test.ts index 2e4095f88a..0ccac5c9b5 100644 --- a/packages/cli/src/modules/test/commands/repo/test.ts +++ b/packages/cli/src/modules/test/commands/repo/test.ts @@ -22,7 +22,7 @@ import yargs from 'yargs'; import { run as runJest, yargsOptions as jestYargsOptions } from 'jest-cli'; import { relative as relativePath } from 'node:path'; import { Command, OptionValues } from 'commander'; -import { Lockfile, PackageGraph } from '@backstage/cli-node'; +import { Lockfile, PackageGraph, SuccessCache } from '@backstage/cli-node'; import { runCheck, @@ -31,7 +31,6 @@ import { findOwnPaths, isChildPath, } from '@backstage/cli-common'; -import { SuccessCache } from '@backstage/cli-node'; type JestProject = { displayName: string; @@ -333,7 +332,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise { ); } - const cache = new SuccessCache('test', opts.successCacheDir); + const cache = SuccessCache.create('test', opts.successCacheDir); const graph = await getPackageGraph(); // Shared state for the bridge From 09eb6b518779f8328969f90dae90a534e3dd2c68 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 15:25:11 +0100 Subject: [PATCH 073/118] Address further review feedback - Rename hasYarnPlugin -> hasBackstageYarnPlugin for clarity - Change SuccessCache.create to accept an options object Signed-off-by: Patrik Oldsberg --- .changeset/move-utils-to-cli-node.md | 2 +- packages/cli-node/report.api.md | 4 ++-- packages/cli-node/src/cache/SuccessCache.ts | 11 ++++++---- packages/cli-node/src/yarn/index.ts | 2 +- packages/cli-node/src/yarn/yarnPlugin.test.ts | 22 +++++++++---------- packages/cli-node/src/yarn/yarnPlugin.ts | 4 +++- .../src/modules/lint/commands/repo/lint.ts | 5 ++++- .../modules/migrate/commands/versions/bump.ts | 4 ++-- .../new/lib/execution/PortableTemplater.ts | 4 ++-- .../src/modules/test/commands/repo/test.ts | 5 ++++- 10 files changed, 37 insertions(+), 26 deletions(-) diff --git a/.changeset/move-utils-to-cli-node.md b/.changeset/move-utils-to-cli-node.md index 7c14536ccf..f708dde666 100644 --- a/.changeset/move-utils-to-cli-node.md +++ b/.changeset/move-utils-to-cli-node.md @@ -2,4 +2,4 @@ '@backstage/cli-node': patch --- -Added `hasYarnPlugin` and `SuccessCache` exports, moved from `@backstage/cli`. +Added `hasBackstageYarnPlugin` and `SuccessCache` exports, moved from `@backstage/cli`. diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index 187f56640c..9d4887ca59 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -100,7 +100,7 @@ export class GitUtils { } // @public -export function hasYarnPlugin(workspaceDir?: string): Promise; +export function hasBackstageYarnPlugin(workspaceDir?: string): Promise; // @public export function isMonoRepo(): Promise; @@ -227,7 +227,7 @@ export function runWorkerQueueThreads( // @public export class SuccessCache { // (undocumented) - static create(name: string, basePath?: string): SuccessCache; + static create(options: { name: string; basePath?: string }): SuccessCache; // (undocumented) read(): Promise>; static trimPaths(input: string): string; diff --git a/packages/cli-node/src/cache/SuccessCache.ts b/packages/cli-node/src/cache/SuccessCache.ts index 62bef34137..78913ed0b6 100644 --- a/packages/cli-node/src/cache/SuccessCache.ts +++ b/packages/cli-node/src/cache/SuccessCache.ts @@ -40,12 +40,15 @@ export class SuccessCache { return input.replaceAll(targetPaths.rootDir, ''); } - static create(name: string, basePath?: string): SuccessCache { - return new SuccessCache(name, basePath); + static create(options: { name: string; basePath?: string }): SuccessCache { + return new SuccessCache(options); } - private constructor(name: string, basePath?: string) { - this.#path = resolvePath(basePath ?? DEFAULT_CACHE_BASE_PATH, name); + private constructor(options: { name: string; basePath?: string }) { + this.#path = resolvePath( + options.basePath ?? DEFAULT_CACHE_BASE_PATH, + options.name, + ); } async read(): Promise> { diff --git a/packages/cli-node/src/yarn/index.ts b/packages/cli-node/src/yarn/index.ts index ce4eb4231c..871aa14ca8 100644 --- a/packages/cli-node/src/yarn/index.ts +++ b/packages/cli-node/src/yarn/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { hasYarnPlugin } from './yarnPlugin'; +export { hasBackstageYarnPlugin } from './yarnPlugin'; diff --git a/packages/cli-node/src/yarn/yarnPlugin.test.ts b/packages/cli-node/src/yarn/yarnPlugin.test.ts index aba1dda579..ecbff45ba7 100644 --- a/packages/cli-node/src/yarn/yarnPlugin.test.ts +++ b/packages/cli-node/src/yarn/yarnPlugin.test.ts @@ -16,12 +16,12 @@ import { createMockDirectory } from '@backstage/backend-test-utils'; import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; -import { hasYarnPlugin } from './yarnPlugin'; +import { hasBackstageYarnPlugin } from './yarnPlugin'; const mockDir = createMockDirectory(); overrideTargetPaths(mockDir.path); -describe('hasYarnPlugin', () => { +describe('hasBackstageYarnPlugin', () => { beforeEach(() => { mockDir.clear(); }); @@ -29,7 +29,7 @@ describe('hasYarnPlugin', () => { it('should return false when .yarnrc.yml does not exist', async () => { mockDir.setContent({}); - const result = await hasYarnPlugin(); + const result = await hasBackstageYarnPlugin(); expect(result).toBe(false); }); @@ -38,7 +38,7 @@ describe('hasYarnPlugin', () => { '.yarnrc.yml': '', }); - const result = await hasYarnPlugin(); + const result = await hasBackstageYarnPlugin(); expect(result).toBe(false); }); @@ -47,7 +47,7 @@ describe('hasYarnPlugin', () => { '.yarnrc.yml': 'plugins: []', }); - const result = await hasYarnPlugin(); + const result = await hasBackstageYarnPlugin(); expect(result).toBe(false); }); @@ -60,7 +60,7 @@ plugins: `, }); - const result = await hasYarnPlugin(); + const result = await hasBackstageYarnPlugin(); expect(result).toBe(false); }); @@ -74,7 +74,7 @@ plugins: `, }); - const result = await hasYarnPlugin(); + const result = await hasBackstageYarnPlugin(); expect(result).toBe(true); }); @@ -86,7 +86,7 @@ plugins: `, }); - const result = await hasYarnPlugin(); + const result = await hasBackstageYarnPlugin(); expect(result).toBe(true); }); @@ -95,7 +95,7 @@ plugins: '.yarnrc.yml': 'invalid: yaml: content: [', }); - await expect(hasYarnPlugin()).rejects.toThrow(); + await expect(hasBackstageYarnPlugin()).rejects.toThrow(); }); it('should throw error when .yarnrc.yml has unexpected structure', async () => { @@ -105,7 +105,7 @@ plugins: "not an array" `, }); - await expect(hasYarnPlugin()).rejects.toThrow( + await expect(hasBackstageYarnPlugin()).rejects.toThrow( 'Unexpected content in .yarnrc.yml', ); }); @@ -120,7 +120,7 @@ plugins: }, }); - const result = await hasYarnPlugin(mockDir.resolve('custom-dir')); + const result = await hasBackstageYarnPlugin(mockDir.resolve('custom-dir')); expect(result).toBe(true); }); }); diff --git a/packages/cli-node/src/yarn/yarnPlugin.ts b/packages/cli-node/src/yarn/yarnPlugin.ts index 87a3b98973..65383edd0c 100644 --- a/packages/cli-node/src/yarn/yarnPlugin.ts +++ b/packages/cli-node/src/yarn/yarnPlugin.ts @@ -37,7 +37,9 @@ const yarnRcSchema = z.object({ * @returns Promise resolving to true if the plugin is installed, false otherwise * @public */ -export async function hasYarnPlugin(workspaceDir?: string): Promise { +export async function hasBackstageYarnPlugin( + workspaceDir?: string, +): Promise { const yarnRcPath = resolvePath( workspaceDir ?? targetPaths.rootDir, '.yarnrc.yml', diff --git a/packages/cli/src/modules/lint/commands/repo/lint.ts b/packages/cli/src/modules/lint/commands/repo/lint.ts index 4351725d57..a3ae69302c 100644 --- a/packages/cli/src/modules/lint/commands/repo/lint.ts +++ b/packages/cli/src/modules/lint/commands/repo/lint.ts @@ -41,7 +41,10 @@ function depCount(pkg: BackstagePackageJson) { export async function command(opts: OptionValues, cmd: Command): Promise { let packages = await PackageGraph.listTargetPackages(); - const cache = SuccessCache.create('lint', opts.successCacheDir); + const cache = SuccessCache.create({ + name: 'lint', + basePath: opts.successCacheDir, + }); const cacheContext = opts.successCache ? { entries: await cache.read(), diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.ts b/packages/cli/src/modules/migrate/commands/versions/bump.ts index 4b15da4676..8fe7013ac8 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.ts @@ -31,7 +31,7 @@ import { isError, NotFoundError } from '@backstage/errors'; import { resolve as resolvePath } from 'node:path'; import { - hasYarnPlugin, + hasBackstageYarnPlugin, Lockfile, runConcurrentTasks, } from '@backstage/cli-node'; @@ -76,7 +76,7 @@ function extendsDefaultPattern(pattern: string): boolean { export default async (opts: OptionValues) => { const lockfilePath = targetPaths.resolveRoot('yarn.lock'); const lockfile = await Lockfile.load(lockfilePath); - const yarnPluginEnabled = await hasYarnPlugin(); + const yarnPluginEnabled = await hasBackstageYarnPlugin(); let pattern = opts.pattern; diff --git a/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts b/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts index 2c94e3639d..42285259b3 100644 --- a/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts +++ b/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts @@ -28,7 +28,7 @@ import { Lockfile } from '@backstage/cli-node'; import { targetPaths } from '@backstage/cli-common'; import { createPackageVersionProvider } from '../../../../lib/version'; -import { hasYarnPlugin } from '@backstage/cli-node'; +import { hasBackstageYarnPlugin } from '@backstage/cli-node'; const builtInHelpers = { camelCase, @@ -55,7 +55,7 @@ export class PortableTemplater { /* ignored */ } - const yarnPluginEnabled = await hasYarnPlugin(); + const yarnPluginEnabled = await hasBackstageYarnPlugin(); const versionProvider = createPackageVersionProvider(lockfile, { preferBackstageProtocol: yarnPluginEnabled, }); diff --git a/packages/cli/src/modules/test/commands/repo/test.ts b/packages/cli/src/modules/test/commands/repo/test.ts index 0ccac5c9b5..53f86fd635 100644 --- a/packages/cli/src/modules/test/commands/repo/test.ts +++ b/packages/cli/src/modules/test/commands/repo/test.ts @@ -332,7 +332,10 @@ export async function command(opts: OptionValues, cmd: Command): Promise { ); } - const cache = SuccessCache.create('test', opts.successCacheDir); + const cache = SuccessCache.create({ + name: 'test', + basePath: opts.successCacheDir, + }); const graph = await getPackageGraph(); // Shared state for the bridge From fb14783d9b137c51e7020a7b3ca03e7b3a726cf6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 15:42:07 +0100 Subject: [PATCH 074/118] ci: trigger Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor From b6412b1b4436643cdb756ff70907a9b6cdeaa4ea Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Wed, 25 Feb 2026 13:32:32 -0600 Subject: [PATCH 075/118] Prettier Signed-off-by: Andre Wanlin --- .../2026-02-25-get-a-jump-on-contribfest.mdx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/microsite/blog/2026-02-25-get-a-jump-on-contribfest.mdx b/microsite/blog/2026-02-25-get-a-jump-on-contribfest.mdx index 7e4eacab50..67941fa436 100644 --- a/microsite/blog/2026-02-25-get-a-jump-on-contribfest.mdx +++ b/microsite/blog/2026-02-25-get-a-jump-on-contribfest.mdx @@ -17,19 +17,19 @@ We're excited to announce the new ContribFest web app: [https://contribfest.back You'll see that the app is broken down into five sections: -* [Welcome](https://contribfest.backstage.io/): This is where you'll find links to all the things, including the session's slide deck, assignment sheet, the Backstage and Community Plugins repositories, and their respective contribution guides. -* [Getting Started](https://contribfest.backstage.io/getting-started/): Whether you are new to Backstage or an old hat, use this handy checklist to help you get your local environment set up for contributing, including all the commands. (Make sure you check all the boxes, you never know what might happen! 😉) -* [Curated Issues](https://contribfest.backstage.io/issues/): This is what you come to the session for: finding an issue that speaks to you and contributing towards it. This section has a list of issues that we've curated — and filters, so you can slice and dice the list to find the perfect issue to work on. -* [Contrib Champs](https://contribfest.backstage.io/contrib-champs/): We've hosted three other ContribFests in the past — this is where you'll find merged PRs from those sessions, a place to celebrate contributions. Make sure to tag your PRs with “ContribFest”, and maybe your name will show up here one day, too! 🏆 -* [Hall of Hosts](https://contribfest.backstage.io/hall-of-hosts/): ContribFest would not take place without the various community members who have stepped up to help co-host the sessions. This is where you'll see an honor roll of past co-hosts. 🙏 +- [Welcome](https://contribfest.backstage.io/): This is where you'll find links to all the things, including the session's slide deck, assignment sheet, the Backstage and Community Plugins repositories, and their respective contribution guides. +- [Getting Started](https://contribfest.backstage.io/getting-started/): Whether you are new to Backstage or an old hat, use this handy checklist to help you get your local environment set up for contributing, including all the commands. (Make sure you check all the boxes, you never know what might happen! 😉) +- [Curated Issues](https://contribfest.backstage.io/issues/): This is what you come to the session for: finding an issue that speaks to you and contributing towards it. This section has a list of issues that we've curated — and filters, so you can slice and dice the list to find the perfect issue to work on. +- [Contrib Champs](https://contribfest.backstage.io/contrib-champs/): We've hosted three other ContribFests in the past — this is where you'll find merged PRs from those sessions, a place to celebrate contributions. Make sure to tag your PRs with “ContribFest”, and maybe your name will show up here one day, too! 🏆 +- [Hall of Hosts](https://contribfest.backstage.io/hall-of-hosts/): ContribFest would not take place without the various community members who have stepped up to help co-host the sessions. This is where you'll see an honor roll of past co-hosts. 🙏 ## About those Contrib Champs The goals of the Backstage ContribFest sessions are many — foster community, work with experts, etc. — but it's pretty obvious that contributions are the most important. It's in the name after all. Here are a few past contributions that we wanted to share to give you an idea of what that looks like: -* [#27694](https://github.com/backstage/backstage/pull/27694) by [hyb175](https://github.com/hyb175) — Add Pagination to Tech Docs Table: for those with lots of entities with TechDocs, this is a massive performance improvement. -* [#29470](https://github.com/backstage/backstage/pull/29470) by [ioboi](https://github.com/ioboi) — Openshift Auth provider: this allows those using OpenShift to use it to sign into their Backstage instance. -* [#31770](https://github.com/backstage/backstage/pull/31770) by [theZMC](https://github.com/theZMC) — Render HTML in GitHub-flavored Markdown: with this change in place, HTML will now render correctly in the MarkdownContent component when you are using the GitHub-flavored Markdown mode. +- [#27694](https://github.com/backstage/backstage/pull/27694) by [hyb175](https://github.com/hyb175) — Add Pagination to Tech Docs Table: for those with lots of entities with TechDocs, this is a massive performance improvement. +- [#29470](https://github.com/backstage/backstage/pull/29470) by [ioboi](https://github.com/ioboi) — Openshift Auth provider: this allows those using OpenShift to use it to sign into their Backstage instance. +- [#31770](https://github.com/backstage/backstage/pull/31770) by [theZMC](https://github.com/theZMC) — Render HTML in GitHub-flavored Markdown: with this change in place, HTML will now render correctly in the MarkdownContent component when you are using the GitHub-flavored Markdown mode. Check out the [Contrib Champs page](https://contribfest.backstage.io/contrib-champs/) to see the full list! @@ -37,12 +37,12 @@ Check out the [Contrib Champs page](https://contribfest.backstage.io/contrib-cha Along with the new ContribFest web app, we are also looking to use Dev Containers this time around to help streamline the session for those who'd like to use that option to get started. On the [Getting Started page](https://contribfest.backstage.io/getting-started/), pick the Dev Containers radio button and then follow the checklist. To give you a quick preview, you'll need to have the following installed: -* Git, you'll need this to be able to pull down the code -* Docker Desktop (or Docker Engine on Linux) -* VS Code with the Dev Containers extension or IntelliJ IDEA Ultimate +- Git, you'll need this to be able to pull down the code +- Docker Desktop (or Docker Engine on Linux) +- VS Code with the Dev Containers extension or IntelliJ IDEA Ultimate Check out our [Dev Containers tutorial](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/devcontainer.md) for a deeper dive into the subject. ## Amsterdam, here we come! -On behalf of the Backstage ContribFest co-host team, thank you for following along. We're looking forward to meeting you in Amsterdam and working together on your contributions. Please be sure to introduce yourself! \ No newline at end of file +On behalf of the Backstage ContribFest co-host team, thank you for following along. We're looking forward to meeting you in Amsterdam and working together on your contributions. Please be sure to introduce yourself! From 1afa036222f5e0002835b3760badafa57125ec9a Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 25 Feb 2026 19:46:17 +0000 Subject: [PATCH 076/118] Cleanup Signed-off-by: Charles de Dreuille --- docs-ui/src/app/snippets.ts | 8 +++--- packages/ui/report.api.md | 27 +++---------------- packages/ui/src/components/Flex/definition.ts | 9 +------ packages/ui/src/components/Grid/definition.ts | 18 ++----------- packages/ui/src/hooks/useBg.tsx | 12 ++++++--- 5 files changed, 18 insertions(+), 56 deletions(-) diff --git a/docs-ui/src/app/snippets.ts b/docs-ui/src/app/snippets.ts index e16463a085..4a93c31831 100644 --- a/docs-ui/src/app/snippets.ts +++ b/docs-ui/src/app/snippets.ts @@ -1,19 +1,19 @@ export const surfacesSnippet = ` - + - + `; -export const adaptiveSnippet = ` +export const adaptiveSnippet = ` {/* automatically set background to neutral-2 */} `; -export const customCardSnippet = ` +export const customCardSnippet = ` Hello World `; diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 3ca30cd73c..1a015747a8 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1012,14 +1012,7 @@ export const FlexDefinition: { 'direction', ]; readonly dataAttributes: { - readonly bg: readonly [ - 'neutral-1', - 'neutral-2', - 'neutral-3', - 'danger', - 'warning', - 'success', - ]; + readonly bg: readonly ['neutral', 'danger', 'warning', 'success']; }; }; @@ -1096,14 +1089,7 @@ export const GridDefinition: { 'py', ]; readonly dataAttributes: { - readonly bg: readonly [ - 'neutral-1', - 'neutral-2', - 'neutral-3', - 'danger', - 'warning', - 'success', - ]; + readonly bg: readonly ['neutral', 'danger', 'warning', 'success']; }; }; @@ -1114,14 +1100,7 @@ export const GridItemDefinition: { }; readonly utilityProps: ['colSpan', 'colEnd', 'colStart', 'rowSpan']; readonly dataAttributes: { - readonly bg: readonly [ - 'neutral-1', - 'neutral-2', - 'neutral-3', - 'danger', - 'warning', - 'success', - ]; + readonly bg: readonly ['neutral', 'danger', 'warning', 'success']; }; }; diff --git a/packages/ui/src/components/Flex/definition.ts b/packages/ui/src/components/Flex/definition.ts index d0452d6b2c..2701ce5e10 100644 --- a/packages/ui/src/components/Flex/definition.ts +++ b/packages/ui/src/components/Flex/definition.ts @@ -45,13 +45,6 @@ export const FlexDefinition = { 'direction', ], dataAttributes: { - bg: [ - 'neutral-1', - 'neutral-2', - 'neutral-3', - 'danger', - 'warning', - 'success', - ] as const, + bg: ['neutral', 'danger', 'warning', 'success'] as const, }, } as const satisfies ComponentDefinition; diff --git a/packages/ui/src/components/Grid/definition.ts b/packages/ui/src/components/Grid/definition.ts index c3acb59274..2f417ab34b 100644 --- a/packages/ui/src/components/Grid/definition.ts +++ b/packages/ui/src/components/Grid/definition.ts @@ -43,14 +43,7 @@ export const GridDefinition = { 'py', ], dataAttributes: { - bg: [ - 'neutral-1', - 'neutral-2', - 'neutral-3', - 'danger', - 'warning', - 'success', - ] as const, + bg: ['neutral', 'danger', 'warning', 'success'] as const, }, } as const satisfies ComponentDefinition; @@ -64,13 +57,6 @@ export const GridItemDefinition = { }, utilityProps: ['colSpan', 'colEnd', 'colStart', 'rowSpan'], dataAttributes: { - bg: [ - 'neutral-1', - 'neutral-2', - 'neutral-3', - 'danger', - 'warning', - 'success', - ] as const, + bg: ['neutral', 'danger', 'warning', 'success'] as const, }, } as const satisfies ComponentDefinition; diff --git a/packages/ui/src/hooks/useBg.tsx b/packages/ui/src/hooks/useBg.tsx index e67c47da49..2284e5f337 100644 --- a/packages/ui/src/hooks/useBg.tsx +++ b/packages/ui/src/hooks/useBg.tsx @@ -108,10 +108,14 @@ export function useBgConsumer(): BgContextValue { * * - `bg` is `undefined` -- transparent, no context change, returns `{ bg: undefined }`. * This is the default for Box, Flex, and Grid (they do **not** auto-increment). - * - `bg` is `'neutral'` -- increments the neutral level from the parent context, - * capping at `neutral-3`. The increment is always relative to the parent; it is - * not possible to pin a container to an explicit neutral level. - * - `bg` is `'danger'` | `'warning'` | `'success'` -- used as-is. + * - `bg` is `'neutral'` -- when the parent bg is neutral, increments the neutral + * level from the parent context, capping at `neutral-3`. When the parent bg is + * an intent (`'danger'` | `'warning'` | `'success'`), the intent passes through + * unchanged (i.e. `bg: 'neutral'` does not override the parent intent). The + * increment is always relative to the parent; it is not possible to pin a + * container to an explicit neutral level. + * - `bg` is `'danger'` | `'warning'` | `'success'` -- sets the bg to that intent + * explicitly, regardless of the parent value. * * **Capping:** * From 0cb56465ddc86f6a6f00f598c0ccb2e17c7efe82 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 10:51:57 +0100 Subject: [PATCH 077/118] fix(module-federation): remove trailing slash from `@mui/material/styles` shared dependency key The shared dependency key `@mui/material/styles/` (with trailing slash) caused module resolution failures because MUI's package.json exports map only defines `./styles`, not `./styles/`. This resulted in errors like: Package subpath './styles/' is not defined by "exports" in @mui/material/package.json Signed-off-by: Patrik Oldsberg --- .changeset/fix-mui-styles-shared-dep.md | 5 +++++ .patches/pr-32996.txt | 1 + packages/frontend-dynamic-feature-loader/src/loader.test.tsx | 2 +- packages/module-federation-common/src/defaults.ts | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-mui-styles-shared-dep.md create mode 100644 .patches/pr-32996.txt diff --git a/.changeset/fix-mui-styles-shared-dep.md b/.changeset/fix-mui-styles-shared-dep.md new file mode 100644 index 0000000000..0154c15559 --- /dev/null +++ b/.changeset/fix-mui-styles-shared-dep.md @@ -0,0 +1,5 @@ +--- +'@backstage/module-federation-common': patch +--- + +Fixed the `@mui/material/styles` shared dependency key by removing a trailing slash that caused module resolution failures with MUI package exports. diff --git a/.patches/pr-32996.txt b/.patches/pr-32996.txt new file mode 100644 index 0000000000..2dd0fde602 --- /dev/null +++ b/.patches/pr-32996.txt @@ -0,0 +1 @@ +Fixes the `@mui/material/styles` shared dependency key by removing a trailing slash that caused module resolution failures with MUI package exports. \ No newline at end of file diff --git a/packages/frontend-dynamic-feature-loader/src/loader.test.tsx b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx index cb066e8b21..081b3c730a 100644 --- a/packages/frontend-dynamic-feature-loader/src/loader.test.tsx +++ b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx @@ -165,7 +165,7 @@ describe('dynamicFrontendFeaturesLoader', () => { shareConfig: { singleton: true, requiredVersion: '*', eager: true }, }, { - name: '@mui/material/styles/', + name: '@mui/material/styles', version: '5.16.14', lib: async () => ({ default: {} }), shareConfig: { singleton: true, requiredVersion: '*', eager: true }, diff --git a/packages/module-federation-common/src/defaults.ts b/packages/module-federation-common/src/defaults.ts index 27e0e292cf..644d4ee480 100644 --- a/packages/module-federation-common/src/defaults.ts +++ b/packages/module-federation-common/src/defaults.ts @@ -55,7 +55,7 @@ const defaultSharedDependencies = { // MUI v5 // not setting import: false for MUI packages as this // will break once Backstage moves to BUI - '@mui/material/styles/': { + '@mui/material/styles': { host: {}, remote: {}, }, From cb6f3907edd0ea8c32a181a312899f49a1492585 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 23:28:48 +0100 Subject: [PATCH 078/118] microsite: add missing status field to plugin directory entry Signed-off-by: Patrik Oldsberg --- .../plugins/balajisiva-backstage-plugin-template-builder.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/microsite/data/plugins/balajisiva-backstage-plugin-template-builder.yaml b/microsite/data/plugins/balajisiva-backstage-plugin-template-builder.yaml index 91ea3e5c6e..6b8227c629 100644 --- a/microsite/data/plugins/balajisiva-backstage-plugin-template-builder.yaml +++ b/microsite/data/plugins/balajisiva-backstage-plugin-template-builder.yaml @@ -8,3 +8,4 @@ documentation: https://github.com/balajisiva/backstage-template-builder#readme iconUrl: https://raw.githubusercontent.com/balajisiva/backstage-template-builder/main/plugins/backstage-template-builder/icon.svg npmPackageName: '@balajisiva/backstage-plugin-template-builder' addedDate: '2026-02-16' +status: active From c482b2c385722b290e6c3551934fd27287e6f136 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 23:35:46 +0100 Subject: [PATCH 079/118] ci: run CI for changes to microsite/data/ The paths-ignore for microsite/** was also skipping CI for changes to microsite/data/plugins/, which is where the plugin directory YAML files live. This meant the verify plugin directory step never ran for the files it was supposed to validate. Switch from paths-ignore to paths with negation so that microsite/data/ changes still trigger CI while other microsite changes remain excluded. Signed-off-by: Patrik Oldsberg --- .github/workflows/ci-noop.yml | 1 + .github/workflows/ci.yml | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-noop.yml b/.github/workflows/ci-noop.yml index d9851778c6..0fca26a12c 100644 --- a/.github/workflows/ci-noop.yml +++ b/.github/workflows/ci-noop.yml @@ -6,6 +6,7 @@ on: pull_request: paths: - 'microsite/**' + - '!microsite/data/**' - 'beps/**' permissions: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ac6120d28..8f2b4af0f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,11 @@ name: CI on: # NOTE: If you change these you must update ci-noop.yml as well pull_request: - paths-ignore: - - 'microsite/**' - - 'beps/**' + paths: + - '**' + - '!microsite/**' + - 'microsite/data/**' + - '!beps/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }} From beab3cc456d3d3408e5785d391ce86b8fd57a39f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Feb 2026 23:06:11 +0100 Subject: [PATCH 080/118] cli: eliminate src/lib/ directory and last cross-module import Split version.ts into two locations: - Template version provider moved to modules/new/lib/version.ts - CLI version info moved to wiring/version.ts Remove publishPreflightCheck from repo fix command (still runs during package prepack). Fix build module self-references to use shorter within-module paths. Signed-off-by: Patrik Oldsberg --- .changeset/cli-final-lib-cleanup.md | 5 ++ .../commands/package/start/startFrontend.ts | 4 +- .../src/modules/build/lib/bundler/config.ts | 2 +- .../modules/maintenance/commands/repo/fix.ts | 4 -- .../new/lib/execution/PortableTemplater.ts | 2 +- .../src/{ => modules/new}/lib/version.test.ts | 0 .../cli/src/{ => modules/new}/lib/version.ts | 57 +++++++------------ packages/cli/src/wiring/CliInitializer.ts | 2 +- packages/cli/src/wiring/version.ts | 29 ++++++++++ 9 files changed, 61 insertions(+), 44 deletions(-) create mode 100644 .changeset/cli-final-lib-cleanup.md rename packages/cli/src/{ => modules/new}/lib/version.test.ts (100%) rename packages/cli/src/{ => modules/new}/lib/version.ts (62%) create mode 100644 packages/cli/src/wiring/version.ts diff --git a/.changeset/cli-final-lib-cleanup.md b/.changeset/cli-final-lib-cleanup.md new file mode 100644 index 0000000000..0884a7fc11 --- /dev/null +++ b/.changeset/cli-final-lib-cleanup.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Internal refactor to remove the `src/lib/` directory and eliminate the last cross-module import in the CLI package. diff --git a/packages/cli/src/modules/build/commands/package/start/startFrontend.ts b/packages/cli/src/modules/build/commands/package/start/startFrontend.ts index c81fd2fd66..689cd20f7a 100644 --- a/packages/cli/src/modules/build/commands/package/start/startFrontend.ts +++ b/packages/cli/src/modules/build/commands/package/start/startFrontend.ts @@ -19,11 +19,11 @@ import { resolve as resolvePath } from 'node:path'; import { getModuleFederationRemoteOptions, serveBundle, -} from '../../../../build/lib/bundler'; +} from '../../../lib/bundler'; import { targetPaths } from '@backstage/cli-common'; import { BackstagePackageJson } from '@backstage/cli-node'; -import { hasReactDomClient } from '../../../../build/lib/bundler/hasReactDomClient'; +import { hasReactDomClient } from '../../../lib/bundler/hasReactDomClient'; interface StartAppOptions { verifyVersions?: boolean; diff --git a/packages/cli/src/modules/build/lib/bundler/config.ts b/packages/cli/src/modules/build/lib/bundler/config.ts index 1fe81cbec8..c3676ecf70 100644 --- a/packages/cli/src/modules/build/lib/bundler/config.ts +++ b/packages/cli/src/modules/build/lib/bundler/config.ts @@ -32,7 +32,7 @@ import pickBy from 'lodash/pickBy'; import { runOutput, targetPaths } from '@backstage/cli-common'; import { transforms } from './transforms'; -import { version } from '../../../../lib/version'; +import { version } from '../../../../wiring/version'; import yn from 'yn'; import { hasReactDomClient } from './hasReactDomClient'; import { createWorkspaceLinkingPlugins } from './linkWorkspaces'; diff --git a/packages/cli/src/modules/maintenance/commands/repo/fix.ts b/packages/cli/src/modules/maintenance/commands/repo/fix.ts index cb745b9b48..de09d8b490 100644 --- a/packages/cli/src/modules/maintenance/commands/repo/fix.ts +++ b/packages/cli/src/modules/maintenance/commands/repo/fix.ts @@ -31,8 +31,6 @@ import { } from 'node:path'; import { targetPaths } from '@backstage/cli-common'; -import { publishPreflightCheck } from '../../../build/lib/publishing'; - const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.json']; /** @@ -507,8 +505,6 @@ export async function command(opts: OptionValues): Promise { fixPluginId, fixPluginPackages, fixPeerModules, - // Run the publish preflight check too, to make sure we don't uncover errors during publishing - publishPreflightCheck, ); } diff --git a/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts b/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts index 42285259b3..dd6f43b9a7 100644 --- a/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts +++ b/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts @@ -27,7 +27,7 @@ import lowerFirst from 'lodash/lowerFirst'; import { Lockfile } from '@backstage/cli-node'; import { targetPaths } from '@backstage/cli-common'; -import { createPackageVersionProvider } from '../../../../lib/version'; +import { createPackageVersionProvider } from '../version'; import { hasBackstageYarnPlugin } from '@backstage/cli-node'; const builtInHelpers = { diff --git a/packages/cli/src/lib/version.test.ts b/packages/cli/src/modules/new/lib/version.test.ts similarity index 100% rename from packages/cli/src/lib/version.test.ts rename to packages/cli/src/modules/new/lib/version.test.ts diff --git a/packages/cli/src/lib/version.ts b/packages/cli/src/modules/new/lib/version.ts similarity index 62% rename from packages/cli/src/lib/version.ts rename to packages/cli/src/modules/new/lib/version.ts index 94337710d6..0849e8a9e8 100644 --- a/packages/cli/src/lib/version.ts +++ b/packages/cli/src/modules/new/lib/version.ts @@ -14,14 +14,9 @@ * limitations under the License. */ -import fs from 'fs-extra'; import semver from 'semver'; -import { findOwnPaths } from '@backstage/cli-common'; import { Lockfile } from '@backstage/cli-node'; -/* eslint-disable-next-line no-restricted-syntax */ -const ownPaths = findOwnPaths(__dirname); - /* eslint-disable @backstage/no-relative-monorepo-imports */ /* This is a list of all packages used by the templates. If dependencies are added or removed, @@ -35,28 +30,28 @@ This does not create an actual dependency on these packages and does not bring i Rollup will extract the value of the version field in each package at build time without leaving any imports in place. */ -import { version as backendPluginApi } from '../../../../packages/backend-plugin-api/package.json'; -import { version as backendTestUtils } from '../../../../packages/backend-test-utils/package.json'; -import { version as catalogClient } from '../../../../packages/catalog-client/package.json'; -import { version as cli } from '../../../../packages/cli/package.json'; -import { version as config } from '../../../../packages/config/package.json'; -import { version as coreAppApi } from '../../../../packages/core-app-api/package.json'; -import { version as coreComponents } from '../../../../packages/core-components/package.json'; -import { version as corePluginApi } from '../../../../packages/core-plugin-api/package.json'; -import { version as devUtils } from '../../../../packages/dev-utils/package.json'; -import { version as errors } from '../../../../packages/errors/package.json'; -import { version as frontendDefaults } from '../../../../packages/frontend-defaults/package.json'; -import { version as frontendPluginApi } from '../../../../packages/frontend-plugin-api/package.json'; -import { version as frontendTestUtils } from '../../../../packages/frontend-test-utils/package.json'; -import { version as testUtils } from '../../../../packages/test-utils/package.json'; -import { version as scaffolderNode } from '../../../../plugins/scaffolder-node/package.json'; -import { version as scaffolderNodeTestUtils } from '../../../../plugins/scaffolder-node-test-utils/package.json'; -import { version as authBackend } from '../../../../plugins/auth-backend/package.json'; -import { version as authBackendModuleGuestProvider } from '../../../../plugins/auth-backend-module-guest-provider/package.json'; -import { version as catalogNode } from '../../../../plugins/catalog-node/package.json'; -import { version as theme } from '../../../../packages/theme/package.json'; -import { version as types } from '../../../../packages/types/package.json'; -import { version as backendDefaults } from '../../../../packages/backend-defaults/package.json'; +import { version as backendPluginApi } from '../../../../../../packages/backend-plugin-api/package.json'; +import { version as backendTestUtils } from '../../../../../../packages/backend-test-utils/package.json'; +import { version as catalogClient } from '../../../../../../packages/catalog-client/package.json'; +import { version as cli } from '../../../../../../packages/cli/package.json'; +import { version as config } from '../../../../../../packages/config/package.json'; +import { version as coreAppApi } from '../../../../../../packages/core-app-api/package.json'; +import { version as coreComponents } from '../../../../../../packages/core-components/package.json'; +import { version as corePluginApi } from '../../../../../../packages/core-plugin-api/package.json'; +import { version as devUtils } from '../../../../../../packages/dev-utils/package.json'; +import { version as errors } from '../../../../../../packages/errors/package.json'; +import { version as frontendDefaults } from '../../../../../../packages/frontend-defaults/package.json'; +import { version as frontendPluginApi } from '../../../../../../packages/frontend-plugin-api/package.json'; +import { version as frontendTestUtils } from '../../../../../../packages/frontend-test-utils/package.json'; +import { version as testUtils } from '../../../../../../packages/test-utils/package.json'; +import { version as scaffolderNode } from '../../../../../../plugins/scaffolder-node/package.json'; +import { version as scaffolderNodeTestUtils } from '../../../../../../plugins/scaffolder-node-test-utils/package.json'; +import { version as authBackend } from '../../../../../../plugins/auth-backend/package.json'; +import { version as authBackendModuleGuestProvider } from '../../../../../../plugins/auth-backend-module-guest-provider/package.json'; +import { version as catalogNode } from '../../../../../../plugins/catalog-node/package.json'; +import { version as theme } from '../../../../../../packages/theme/package.json'; +import { version as types } from '../../../../../../packages/types/package.json'; +import { version as backendDefaults } from '../../../../../../packages/backend-defaults/package.json'; export const packageVersions: Record = { '@backstage/backend-defaults': backendDefaults, @@ -84,14 +79,6 @@ export const packageVersions: Record = { '@backstage/plugin-catalog-node': catalogNode, }; -export function findVersion() { - const pkgContent = fs.readFileSync(ownPaths.resolve('package.json'), 'utf8'); - return JSON.parse(pkgContent).version; -} - -export const version = findVersion(); -export const isDev = fs.pathExistsSync(ownPaths.resolve('src')); - export function createPackageVersionProvider( lockfile?: Lockfile, options?: { diff --git a/packages/cli/src/wiring/CliInitializer.ts b/packages/cli/src/wiring/CliInitializer.ts index 8d0b844c78..a605199179 100644 --- a/packages/cli/src/wiring/CliInitializer.ts +++ b/packages/cli/src/wiring/CliInitializer.ts @@ -18,7 +18,7 @@ import { CommandGraph } from './CommandGraph'; import { CliFeature, OpaqueCliPlugin } from './types'; import { CommandRegistry } from './CommandRegistry'; import { Command } from 'commander'; -import { version } from '../lib/version'; +import { version } from './version'; import chalk from 'chalk'; import { exitWithError } from './errors'; import { ForwardedError } from '@backstage/errors'; diff --git a/packages/cli/src/wiring/version.ts b/packages/cli/src/wiring/version.ts new file mode 100644 index 0000000000..d7d0b3a2fe --- /dev/null +++ b/packages/cli/src/wiring/version.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 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 fs from 'fs-extra'; +import { findOwnPaths } from '@backstage/cli-common'; + +/* eslint-disable-next-line no-restricted-syntax */ +const ownPaths = findOwnPaths(__dirname); + +export function findVersion() { + const pkgContent = fs.readFileSync(ownPaths.resolve('package.json'), 'utf8'); + return JSON.parse(pkgContent).version; +} + +export const version = findVersion(); +export const isDev = fs.pathExistsSync(ownPaths.resolve('src')); From 3aa4af57ecdebd72239a33afa2c33ff68ba2f78a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:57:15 +0000 Subject: [PATCH 081/118] build(deps): bump basic-ftp from 5.0.3 to 5.2.0 Bumps [basic-ftp](https://github.com/patrickjuchli/basic-ftp) from 5.0.3 to 5.2.0. - [Release notes](https://github.com/patrickjuchli/basic-ftp/releases) - [Changelog](https://github.com/patrickjuchli/basic-ftp/blob/master/CHANGELOG.md) - [Commits](https://github.com/patrickjuchli/basic-ftp/compare/v5.0.3...v5.2.0) --- updated-dependencies: - dependency-name: basic-ftp dependency-version: 5.2.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 1447977846..8650a19f0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26091,9 +26091,9 @@ __metadata: linkType: hard "basic-ftp@npm:^5.0.2": - version: 5.0.3 - resolution: "basic-ftp@npm:5.0.3" - checksum: 10/8f69811a7f4088c5ae39025f1a662522aad517b4065365fbbe80676dc9ccf7a188463dfcb2d9573af2af859d7de70015f612c20a04a311b520efbf7c21dc1ff2 + version: 5.2.0 + resolution: "basic-ftp@npm:5.2.0" + checksum: 10/f5a15d789aa98859af4da9e976154b2aeae19052e1762dc68d259d2bce631dafa40c667aa06d7346cd630aa6f9cc9a26f515b468e0bd24243fbae2149c7d01ad languageName: node linkType: hard From 5411f0a8f83aa0cab6b894a484f7e4e3ce3c56a1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 00:03:40 +0100 Subject: [PATCH 082/118] remove changeset Signed-off-by: Patrik Oldsberg --- .changeset/cli-final-lib-cleanup.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/cli-final-lib-cleanup.md diff --git a/.changeset/cli-final-lib-cleanup.md b/.changeset/cli-final-lib-cleanup.md deleted file mode 100644 index 0884a7fc11..0000000000 --- a/.changeset/cli-final-lib-cleanup.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Internal refactor to remove the `src/lib/` directory and eliminate the last cross-module import in the CLI package. From 4d588942d5c78be5707ff513182f568df66f984b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 00:15:17 +0100 Subject: [PATCH 083/118] Add group aliases and configurable content ordering to entity page Add two new configuration features for entity page groups: - Group alias IDs: groups can declare aliases so that content targeting an aliased group ID is included in the aliasing group - Configurable content ordering: a new contentOrder option (alpha/natural) controls how content items within each group are sorted, with support for both a page-level default and per-group overrides. The new default is alpha (alphabetical by title). Signed-off-by: Patrik Oldsberg --- ...page-group-aliases-and-ordering-catalog.md | 5 + .../entity-page-group-aliases-and-ordering.md | 5 + packages/app/app-config.yaml | 5 + plugins/catalog-react/report-alpha.api.md | 13 +- .../src/alpha/blueprints/extensionData.tsx | 15 +- .../src/alpha/blueprints/index.ts | 1 + plugins/catalog/report-alpha.api.md | 6 + .../components/EntityLayout/EntityLayout.tsx | 3 + .../components/EntityTabs/EntityTabs.tsx | 4 +- .../components/EntityTabs/EntityTabsList.tsx | 47 ++++- plugins/catalog/src/alpha/pages.test.tsx | 180 ++++++++++++++++++ plugins/catalog/src/alpha/pages.tsx | 5 + 12 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 .changeset/entity-page-group-aliases-and-ordering-catalog.md create mode 100644 .changeset/entity-page-group-aliases-and-ordering.md diff --git a/.changeset/entity-page-group-aliases-and-ordering-catalog.md b/.changeset/entity-page-group-aliases-and-ordering-catalog.md new file mode 100644 index 0000000000..b29bd483bc --- /dev/null +++ b/.changeset/entity-page-group-aliases-and-ordering-catalog.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': minor +--- + +Added support for group alias IDs and configurable content ordering on the entity page. Groups can now declare `aliases` so that content targeting an aliased group is included in the group. A new `contentOrder` option (default `alpha`) controls how content items within each group are sorted, with support for both a page-level default and per-group overrides. diff --git a/.changeset/entity-page-group-aliases-and-ordering.md b/.changeset/entity-page-group-aliases-and-ordering.md new file mode 100644 index 0000000000..35b18063d9 --- /dev/null +++ b/.changeset/entity-page-group-aliases-and-ordering.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +Added `aliases` and `contentOrder` fields to `EntityContentGroupDefinition`, allowing groups to declare alias IDs and control the sort order of their content items. diff --git a/packages/app/app-config.yaml b/packages/app/app-config.yaml index 0fef43fbb5..d22a7e2fbd 100644 --- a/packages/app/app-config.yaml +++ b/packages/app/app-config.yaml @@ -48,6 +48,8 @@ app: - page:catalog/entity: config: showNavItemIcons: true + # default content order for all groups, can be 'alpha' or 'natural' + # contentOrder: alpha groups: # placing a tab at the beginning - overview: @@ -58,6 +60,9 @@ app: - documentation: title: Docs icon: docs + # example aliasing a group + # aliases: + # - docs - deployment: title: Deployments # example adding a new group diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index acd85b5540..a9a054da26 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -339,13 +339,18 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ }; }>; +// @alpha (undocumented) +export type EntityContentGroupDefinition = { + title: string; + icon?: string | ReactElement; + aliases?: string[]; + contentOrder?: 'alpha' | 'natural'; +}; + // @alpha (undocumented) export type EntityContentGroupDefinitions = Record< string, - { - title: string; - icon?: string | ReactElement; - } + EntityContentGroupDefinition >; // @alpha (undocumented) diff --git a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx index 83b9aa983b..292c9f367a 100644 --- a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx @@ -41,13 +41,20 @@ export const entityFilterExpressionDataRef = id: 'catalog.entity-filter-expression', }); +/** @alpha */ +export type EntityContentGroupDefinition = { + title: string; + icon?: string | ReactElement; + /** Other group IDs that should be treated as aliases for this group. */ + aliases?: string[]; + /** How to sort the content items within this group. Overrides the page-level default. */ + contentOrder?: 'alpha' | 'natural'; +}; + /** @alpha */ export type EntityContentGroupDefinitions = Record< string, - { - title: string; - icon?: string | ReactElement; - } + EntityContentGroupDefinition >; /** diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index 07401018cb..507511358e 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -24,6 +24,7 @@ export { EntityHeaderBlueprint } from './EntityHeaderBlueprint'; export { defaultEntityContentGroups, defaultEntityContentGroupDefinitions, + type EntityContentGroupDefinition, type EntityContentGroupDefinitions, } from './extensionData'; export type { EntityCardType } from './extensionData'; diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index c2d856561f..d164c4401f 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -1097,9 +1097,12 @@ const _default: OverridableFrontendPlugin< { title: string; icon?: string | undefined; + contentOrder?: 'alpha' | 'natural' | undefined; + aliases?: string[] | undefined; } >[] | undefined; + contentOrder: 'alpha' | 'natural'; showNavItemIcons: boolean; path: string | undefined; title: string | undefined; @@ -1111,10 +1114,13 @@ const _default: OverridableFrontendPlugin< { title: string; icon?: string | undefined; + contentOrder?: 'alpha' | 'natural' | undefined; + aliases?: string[] | undefined; } >[] | undefined; showNavItemIcons?: boolean | undefined; + contentOrder?: 'alpha' | 'natural' | undefined; title?: string | undefined; path?: string | undefined; }; diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx index ee6e2fc012..1e86c92ef8 100644 --- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx @@ -80,6 +80,7 @@ export interface EntityLayoutProps { */ parentEntityRelations?: string[]; groupDefinitions: EntityContentGroupDefinitions; + defaultContentOrder?: 'alpha' | 'natural'; showNavItemIcons?: boolean; } @@ -110,6 +111,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { NotFoundComponent, parentEntityRelations, groupDefinitions, + defaultContentOrder, showNavItemIcons, } = props; const { kind } = useRouteRefParams(entityRouteRef); @@ -164,6 +166,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { )} diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx index ed737266a5..14a2e755a6 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx @@ -72,11 +72,12 @@ export function useSelectedSubRoute(subRoutes: SubRoute[]): { type EntityTabsProps = { routes: SubRoute[]; groupDefinitions: EntityContentGroupDefinitions; + defaultContentOrder?: 'alpha' | 'natural'; showIcons?: boolean; }; export function EntityTabs(props: EntityTabsProps) { - const { routes, groupDefinitions, showIcons } = props; + const { routes, groupDefinitions, defaultContentOrder, showIcons } = props; const { index, route, element } = useSelectedSubRoute(routes); @@ -107,6 +108,7 @@ export function EntityTabs(props: EntityTabsProps) { selectedIndex={index} showIcons={showIcons} groupDefinitions={groupDefinitions} + defaultContentOrder={defaultContentOrder} /> diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx index 9267e083f5..3f5d735e30 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx @@ -78,6 +78,7 @@ type TabGroup = { type EntityTabsListProps = { tabs: Tab[]; groupDefinitions: EntityContentGroupDefinitions; + defaultContentOrder?: 'alpha' | 'natural'; showIcons?: boolean; selectedIndex?: number; }; @@ -86,12 +87,36 @@ export function EntityTabsList(props: EntityTabsListProps) { const styles = useStyles(); const { t } = useTranslationRef(catalogTranslationRef); - const { tabs: items, selectedIndex = 0, showIcons, groupDefinitions } = props; + const { + tabs: items, + selectedIndex = 0, + showIcons, + groupDefinitions, + defaultContentOrder = 'alpha', + } = props; + + const aliasToGroup = useMemo( + () => + Object.entries(groupDefinitions).reduce((map, [groupId, def]) => { + for (const alias of def.aliases ?? []) { + map[alias] = groupId; + } + return map; + }, {} as Record), + [groupDefinitions], + ); const groups = useMemo(() => { const byKey = items.reduce((result, tab) => { - const group = tab.group ? groupDefinitions[tab.group] : undefined; - const groupOrId = group && tab.group ? tab.group : tab.id; + const resolvedGroupId = tab.group + ? groupDefinitions[tab.group] + ? tab.group + : aliasToGroup[tab.group] + : undefined; + const group = resolvedGroupId + ? groupDefinitions[resolvedGroupId] + : undefined; + const groupOrId = group && resolvedGroupId ? resolvedGroupId : tab.id; result[groupOrId] = result[groupOrId] ?? { group, items: [], @@ -101,7 +126,7 @@ export function EntityTabsList(props: EntityTabsListProps) { }, {} as Record); const groupOrder = Object.keys(groupDefinitions); - return Object.entries(byKey).sort(([a], [b]) => { + const sorted = Object.entries(byKey).sort(([a], [b]) => { const ai = groupOrder.indexOf(a); const bi = groupOrder.indexOf(b); if (ai !== -1 && bi !== -1) { @@ -115,7 +140,19 @@ export function EntityTabsList(props: EntityTabsListProps) { } return 0; }); - }, [items, groupDefinitions]); + + for (const [id, tabGroup] of sorted) { + const groupDef = groupDefinitions[id]; + const order = groupDef?.contentOrder ?? defaultContentOrder; + if (order === 'alpha') { + tabGroup.items.sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }), + ); + } + } + + return sorted; + }, [items, groupDefinitions, aliasToGroup, defaultContentOrder]); const selectedItem = items[selectedIndex]; return ( diff --git a/plugins/catalog/src/alpha/pages.test.tsx b/plugins/catalog/src/alpha/pages.test.tsx index e6bf4a05cf..2c6dfb5c9f 100644 --- a/plugins/catalog/src/alpha/pages.test.tsx +++ b/plugins/catalog/src/alpha/pages.test.tsx @@ -428,6 +428,186 @@ describe('Entity page', () => { expect(screen.getAllByRole('tab')[1]).toHaveTextContent('Overview'); }); + it('Should resolve group aliases', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + { + config: { + groups: [ + { + docs: { title: 'Docs', aliases: ['documentation'] }, + }, + ], + }, + }, + ) + .add(techdocsEntityContent) + .add(apidocsEntityContent); + + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); + + await waitFor(() => + expect(screen.getByRole('tab', { name: /Docs/ })).toBeInTheDocument(), + ); + + await userEvent.click(screen.getByRole('tab', { name: /Docs/ })); + + await waitFor(() => + expect( + screen.getByRole('button', { name: /TechDocs/ }), + ).toHaveAttribute('href', '/techdocs'), + ); + + await waitFor(() => + expect(screen.getByRole('button', { name: /ApiDocs/ })).toHaveAttribute( + 'href', + '/apidocs', + ), + ); + }); + + it('Should sort content alphabetically by default', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + ) + .add(techdocsEntityContent) + .add(apidocsEntityContent); + + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); + + await userEvent.click( + await screen.findByRole('tab', { name: /Documentation/ }), + ); + + const buttons = await screen.findAllByRole('button', { + name: /Docs/, + }); + expect(buttons[0]).toHaveTextContent('ApiDocs'); + expect(buttons[1]).toHaveTextContent('TechDocs'); + }); + + it('Should preserve natural order when configured', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + { + config: { + contentOrder: 'natural', + }, + }, + ) + .add(techdocsEntityContent) + .add(apidocsEntityContent); + + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); + + await userEvent.click( + await screen.findByRole('tab', { name: /Documentation/ }), + ); + + const buttons = await screen.findAllByRole('button', { + name: /Docs/, + }); + expect(buttons[0]).toHaveTextContent('TechDocs'); + expect(buttons[1]).toHaveTextContent('ApiDocs'); + }); + + it('Should support per-group content order override', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + { + config: { + contentOrder: 'alpha', + groups: [ + { + documentation: { + title: 'Documentation', + contentOrder: 'natural', + }, + }, + ], + }, + }, + ) + .add(techdocsEntityContent) + .add(apidocsEntityContent); + + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); + + await userEvent.click( + await screen.findByRole('tab', { name: /Documentation/ }), + ); + + const buttons = await screen.findAllByRole('button', { + name: /Docs/, + }); + expect(buttons[0]).toHaveTextContent('TechDocs'); + expect(buttons[1]).toHaveTextContent('ApiDocs'); + }); + it('Should render groups on the correct order', async () => { const tester = createExtensionTester( Object.assign({ namespace: 'catalog' }, catalogEntityPage), diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index acb9ec8d51..40aa217442 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -109,10 +109,14 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ z.object({ title: z.string(), icon: z.string().optional(), + aliases: z.array(z.string()).optional(), + contentOrder: z.enum(['alpha', 'natural']).optional(), }), ), ) .optional(), + contentOrder: z => + z.enum(['alpha', 'natural']).optional().default('alpha'), showNavItemIcons: z => z.boolean().optional().default(false), }, }, @@ -174,6 +178,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ header={header} contextMenuItems={filteredMenuItems} groupDefinitions={groupDefinitions} + defaultContentOrder={config.contentOrder} showNavItemIcons={config.showNavItemIcons} > {inputs.contents.map(output => ( From deb4476146018f963c879dfcdaa09de2a76555a0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 00:32:23 +0100 Subject: [PATCH 084/118] Address review feedback - Rename 'alpha' to 'title' for content order enum value - Inline EntityContentGroupDefinition type into EntityContentGroupDefinitions - Rename defaultContentOrder prop to contentOrder - Apply content ordering to ungrouped tabs as well - Fix no-nested-ternary lint error Signed-off-by: Patrik Oldsberg --- ...page-group-aliases-and-ordering-catalog.md | 2 +- packages/app/app-config.yaml | 4 +-- plugins/catalog-react/report-alpha.api.md | 15 ++++---- .../src/alpha/blueprints/extensionData.tsx | 19 +++++------ .../src/alpha/blueprints/index.ts | 1 - plugins/catalog/report-alpha.api.md | 8 ++--- .../components/EntityLayout/EntityLayout.tsx | 6 ++-- .../components/EntityTabs/EntityTabs.tsx | 6 ++-- .../components/EntityTabs/EntityTabsList.tsx | 34 +++++++++++++------ plugins/catalog/src/alpha/pages.test.tsx | 4 +-- plugins/catalog/src/alpha/pages.tsx | 6 ++-- 11 files changed, 56 insertions(+), 49 deletions(-) diff --git a/.changeset/entity-page-group-aliases-and-ordering-catalog.md b/.changeset/entity-page-group-aliases-and-ordering-catalog.md index b29bd483bc..c2a17ae773 100644 --- a/.changeset/entity-page-group-aliases-and-ordering-catalog.md +++ b/.changeset/entity-page-group-aliases-and-ordering-catalog.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog': minor --- -Added support for group alias IDs and configurable content ordering on the entity page. Groups can now declare `aliases` so that content targeting an aliased group is included in the group. A new `contentOrder` option (default `alpha`) controls how content items within each group are sorted, with support for both a page-level default and per-group overrides. +Added support for group alias IDs and configurable content ordering on the entity page. Groups can now declare `aliases` so that content targeting an aliased group is included in the group. A new `contentOrder` option (default `title`) controls how content items within each group are sorted, with support for both a page-level default and per-group overrides. diff --git a/packages/app/app-config.yaml b/packages/app/app-config.yaml index d22a7e2fbd..0e49365c08 100644 --- a/packages/app/app-config.yaml +++ b/packages/app/app-config.yaml @@ -48,8 +48,8 @@ app: - page:catalog/entity: config: showNavItemIcons: true - # default content order for all groups, can be 'alpha' or 'natural' - # contentOrder: alpha + # default content order for all groups, can be 'title' or 'natural' + # contentOrder: title groups: # placing a tab at the beginning - overview: diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index a9a054da26..9c76a221dc 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -339,18 +339,15 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ }; }>; -// @alpha (undocumented) -export type EntityContentGroupDefinition = { - title: string; - icon?: string | ReactElement; - aliases?: string[]; - contentOrder?: 'alpha' | 'natural'; -}; - // @alpha (undocumented) export type EntityContentGroupDefinitions = Record< string, - EntityContentGroupDefinition + { + title: string; + icon?: string | ReactElement; + aliases?: string[]; + contentOrder?: 'title' | 'natural'; + } >; // @alpha (undocumented) diff --git a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx index 292c9f367a..04c13db484 100644 --- a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx @@ -41,20 +41,17 @@ export const entityFilterExpressionDataRef = id: 'catalog.entity-filter-expression', }); -/** @alpha */ -export type EntityContentGroupDefinition = { - title: string; - icon?: string | ReactElement; - /** Other group IDs that should be treated as aliases for this group. */ - aliases?: string[]; - /** How to sort the content items within this group. Overrides the page-level default. */ - contentOrder?: 'alpha' | 'natural'; -}; - /** @alpha */ export type EntityContentGroupDefinitions = Record< string, - EntityContentGroupDefinition + { + title: string; + icon?: string | ReactElement; + /** Other group IDs that should be treated as aliases for this group. */ + aliases?: string[]; + /** How to sort the content items within this group. Overrides the page-level default. */ + contentOrder?: 'title' | 'natural'; + } >; /** diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index 507511358e..07401018cb 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -24,7 +24,6 @@ export { EntityHeaderBlueprint } from './EntityHeaderBlueprint'; export { defaultEntityContentGroups, defaultEntityContentGroupDefinitions, - type EntityContentGroupDefinition, type EntityContentGroupDefinitions, } from './extensionData'; export type { EntityCardType } from './extensionData'; diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index d164c4401f..2c44d0cefc 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -1097,12 +1097,12 @@ const _default: OverridableFrontendPlugin< { title: string; icon?: string | undefined; - contentOrder?: 'alpha' | 'natural' | undefined; + contentOrder?: 'title' | 'natural' | undefined; aliases?: string[] | undefined; } >[] | undefined; - contentOrder: 'alpha' | 'natural'; + contentOrder: 'title' | 'natural'; showNavItemIcons: boolean; path: string | undefined; title: string | undefined; @@ -1114,13 +1114,13 @@ const _default: OverridableFrontendPlugin< { title: string; icon?: string | undefined; - contentOrder?: 'alpha' | 'natural' | undefined; + contentOrder?: 'title' | 'natural' | undefined; aliases?: string[] | undefined; } >[] | undefined; + contentOrder?: 'title' | 'natural' | undefined; showNavItemIcons?: boolean | undefined; - contentOrder?: 'alpha' | 'natural' | undefined; title?: string | undefined; path?: string | undefined; }; diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx index 1e86c92ef8..ecee929cde 100644 --- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx @@ -80,7 +80,7 @@ export interface EntityLayoutProps { */ parentEntityRelations?: string[]; groupDefinitions: EntityContentGroupDefinitions; - defaultContentOrder?: 'alpha' | 'natural'; + contentOrder?: 'title' | 'natural'; showNavItemIcons?: boolean; } @@ -111,7 +111,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { NotFoundComponent, parentEntityRelations, groupDefinitions, - defaultContentOrder, + contentOrder, showNavItemIcons, } = props; const { kind } = useRouteRefParams(entityRouteRef); @@ -166,7 +166,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { )} diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx index 14a2e755a6..22ae70d726 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx @@ -72,12 +72,12 @@ export function useSelectedSubRoute(subRoutes: SubRoute[]): { type EntityTabsProps = { routes: SubRoute[]; groupDefinitions: EntityContentGroupDefinitions; - defaultContentOrder?: 'alpha' | 'natural'; + contentOrder?: 'title' | 'natural'; showIcons?: boolean; }; export function EntityTabs(props: EntityTabsProps) { - const { routes, groupDefinitions, defaultContentOrder, showIcons } = props; + const { routes, groupDefinitions, contentOrder, showIcons } = props; const { index, route, element } = useSelectedSubRoute(routes); @@ -108,7 +108,7 @@ export function EntityTabs(props: EntityTabsProps) { selectedIndex={index} showIcons={showIcons} groupDefinitions={groupDefinitions} - defaultContentOrder={defaultContentOrder} + contentOrder={contentOrder} /> diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx index 3f5d735e30..e55129c573 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx @@ -78,11 +78,25 @@ type TabGroup = { type EntityTabsListProps = { tabs: Tab[]; groupDefinitions: EntityContentGroupDefinitions; - defaultContentOrder?: 'alpha' | 'natural'; + contentOrder?: 'title' | 'natural'; showIcons?: boolean; selectedIndex?: number; }; +function resolveGroupId( + tabGroup: string | undefined, + groupDefinitions: EntityContentGroupDefinitions, + aliasToGroup: Record, +): string | undefined { + if (!tabGroup) { + return undefined; + } + if (groupDefinitions[tabGroup]) { + return tabGroup; + } + return aliasToGroup[tabGroup]; +} + export function EntityTabsList(props: EntityTabsListProps) { const styles = useStyles(); const { t } = useTranslationRef(catalogTranslationRef); @@ -92,7 +106,7 @@ export function EntityTabsList(props: EntityTabsListProps) { selectedIndex = 0, showIcons, groupDefinitions, - defaultContentOrder = 'alpha', + contentOrder = 'title', } = props; const aliasToGroup = useMemo( @@ -108,11 +122,11 @@ export function EntityTabsList(props: EntityTabsListProps) { const groups = useMemo(() => { const byKey = items.reduce((result, tab) => { - const resolvedGroupId = tab.group - ? groupDefinitions[tab.group] - ? tab.group - : aliasToGroup[tab.group] - : undefined; + const resolvedGroupId = resolveGroupId( + tab.group, + groupDefinitions, + aliasToGroup, + ); const group = resolvedGroupId ? groupDefinitions[resolvedGroupId] : undefined; @@ -143,8 +157,8 @@ export function EntityTabsList(props: EntityTabsListProps) { for (const [id, tabGroup] of sorted) { const groupDef = groupDefinitions[id]; - const order = groupDef?.contentOrder ?? defaultContentOrder; - if (order === 'alpha') { + const order = groupDef?.contentOrder ?? contentOrder; + if (order === 'title') { tabGroup.items.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }), ); @@ -152,7 +166,7 @@ export function EntityTabsList(props: EntityTabsListProps) { } return sorted; - }, [items, groupDefinitions, aliasToGroup, defaultContentOrder]); + }, [items, groupDefinitions, aliasToGroup, contentOrder]); const selectedItem = items[selectedIndex]; return ( diff --git a/plugins/catalog/src/alpha/pages.test.tsx b/plugins/catalog/src/alpha/pages.test.tsx index 2c6dfb5c9f..39f96f8d5e 100644 --- a/plugins/catalog/src/alpha/pages.test.tsx +++ b/plugins/catalog/src/alpha/pages.test.tsx @@ -482,7 +482,7 @@ describe('Entity page', () => { ); }); - it('Should sort content alphabetically by default', async () => { + it('Should sort content by title by default', async () => { const tester = createExtensionTester( Object.assign({ namespace: 'catalog' }, catalogEntityPage), ) @@ -564,7 +564,7 @@ describe('Entity page', () => { Object.assign({ namespace: 'catalog' }, catalogEntityPage), { config: { - contentOrder: 'alpha', + contentOrder: 'title', groups: [ { documentation: { diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index 40aa217442..61801478f7 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -110,13 +110,13 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ title: z.string(), icon: z.string().optional(), aliases: z.array(z.string()).optional(), - contentOrder: z.enum(['alpha', 'natural']).optional(), + contentOrder: z.enum(['title', 'natural']).optional(), }), ), ) .optional(), contentOrder: z => - z.enum(['alpha', 'natural']).optional().default('alpha'), + z.enum(['title', 'natural']).optional().default('title'), showNavItemIcons: z => z.boolean().optional().default(false), }, }, @@ -178,7 +178,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ header={header} contextMenuItems={filteredMenuItems} groupDefinitions={groupDefinitions} - defaultContentOrder={config.contentOrder} + contentOrder={config.contentOrder} showNavItemIcons={config.showNavItemIcons} > {inputs.contents.map(output => ( From 62f0a53d65f535f83002c29007c75e45be077c3d Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Thu, 26 Feb 2026 09:59:58 +0100 Subject: [PATCH 085/118] Fix error forwarding in actions registry to preserve original status codes (#33021) Signed-off-by: benjdlambert --- .changeset/fix-mcp-error-forwarding.md | 7 + .../DefaultActionsRegistryService.ts | 42 ++--- .../actionsRegistryServiceFactory.test.ts | 33 +++- .../src/alpha/services/MockActionsRegistry.ts | 37 ++--- ...reateRegisterCatalogEntitiesAction.test.ts | 11 +- ...ateUnregisterCatalogEntitiesAction.test.ts | 49 ++---- .../src/services/McpService.test.ts | 113 ++++++++++++++ .../src/services/handleErrors.test.ts | 144 ++++++++++++++++++ .../src/services/handleErrors.ts | 6 +- 9 files changed, 345 insertions(+), 97 deletions(-) create mode 100644 .changeset/fix-mcp-error-forwarding.md create mode 100644 plugins/mcp-actions-backend/src/services/handleErrors.test.ts diff --git a/.changeset/fix-mcp-error-forwarding.md b/.changeset/fix-mcp-error-forwarding.md new file mode 100644 index 0000000000..fa12e224a9 --- /dev/null +++ b/.changeset/fix-mcp-error-forwarding.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-defaults': patch +'@backstage/backend-test-utils': patch +'@backstage/plugin-mcp-actions-backend': patch +--- + +Fixed error forwarding in the actions registry so that known errors like `InputError` and `NotFoundError` thrown by actions preserve their original status codes and messages instead of being wrapped in `ForwardedError` and coerced to 500. diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index 7348e0a185..ffe74f81d9 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -28,12 +28,7 @@ import { ActionsRegistryActionOptions, ActionsRegistryService, } from '@backstage/backend-plugin-api/alpha'; -import { - ForwardedError, - InputError, - NotAllowedError, - NotFoundError, -} from '@backstage/errors'; +import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; export class DefaultActionsRegistryService implements ActionsRegistryService { private actions: Map> = @@ -131,31 +126,24 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { ); } - try { - const result = await action.action({ - input: input.data, - credentials, - logger: this.logger, - }); + const result = await action.action({ + input: input.data, + credentials, + logger: this.logger, + }); - const output = action.schema?.output - ? action.schema.output(z).safeParse(result?.output) - : ({ success: true, data: result?.output } as const); + const output = action.schema?.output + ? action.schema.output(z).safeParse(result?.output) + : ({ success: true, data: result?.output } as const); - if (!output.success) { - throw new InputError( - `Invalid output from action "${req.params.actionId}"`, - output.error, - ); - } - - res.json({ output: output.data }); - } catch (error) { - throw new ForwardedError( - `Failed execution of action "${req.params.actionId}"`, - error, + if (!output.success) { + throw new InputError( + `Invalid output from action "${req.params.actionId}"`, + output.error, ); } + + res.json({ output: output.data }); }, ); return router; diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index 79c103c284..1e7c27960e 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -22,7 +22,7 @@ import { import { httpRouterServiceFactory } from '../../../entrypoints/httpRouter'; import request from 'supertest'; import { actionsRegistryServiceFactory } from './actionsRegistryServiceFactory'; -import { InputError } from '@backstage/errors'; +import { InputError, NotFoundError } from '@backstage/errors'; import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; describe('actionsRegistryServiceFactory', () => { @@ -510,7 +510,7 @@ describe('actionsRegistryServiceFactory', () => { expect(body).toMatchObject({ output: { ok: true } }); }); - it('should return the error from the action if it throws', async () => { + it('should forward the original error when the action throws a known error', async () => { const { server } = await startTestBackend({ features: [pluginSubject, ...defaultServices], }); @@ -528,9 +528,32 @@ describe('actionsRegistryServiceFactory', () => { expect(status).toBe(400); expect(body).toMatchObject({ error: { - message: expect.stringContaining( - 'Failed execution of action "my-plugin:test"', - ), + name: 'InputError', + message: 'test', + }, + }); + }); + + it('should forward a NotFoundError from the action with 404 status', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + mockAction.mockRejectedValue(new NotFoundError('entity not found')); + + const { body, status } = await request(server) + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) + .send({ + name: 'test', + }); + + expect(status).toBe(404); + expect(body).toMatchObject({ + error: { + name: 'NotFoundError', + message: 'entity not found', }, }); }); diff --git a/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts b/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts index 94e5987afd..384a5e8e95 100644 --- a/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts +++ b/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts @@ -17,7 +17,7 @@ import { BackstageCredentials, LoggerService, } from '@backstage/backend-plugin-api'; -import { ForwardedError, InputError, NotFoundError } from '@backstage/errors'; +import { InputError, NotFoundError } from '@backstage/errors'; import { JsonObject, JsonValue } from '@backstage/types'; import { z, AnyZodObject } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; @@ -126,31 +126,24 @@ export class MockActionsRegistry throw new InputError(`Invalid input to action "${opts.id}"`, input.error); } - try { - const result = await action.action({ - input: input.data, - credentials: opts.credentials ?? mockCredentials.none(), - logger: this.logger, - }); + const result = await action.action({ + input: input.data, + credentials: opts.credentials ?? mockCredentials.none(), + logger: this.logger, + }); - const output = action.schema?.output - ? action.schema.output(z).safeParse(result?.output) - : ({ success: true, data: result?.output } as const); + const output = action.schema?.output + ? action.schema.output(z).safeParse(result?.output) + : ({ success: true, data: result?.output } as const); - if (!output.success) { - throw new InputError( - `Invalid output from action "${opts.id}"`, - output.error, - ); - } - - return { output: output.data }; - } catch (error) { - throw new ForwardedError( - `Failed execution of action "${opts.id}"`, - error, + if (!output.success) { + throw new InputError( + `Invalid output from action "${opts.id}"`, + output.error, ); } + + return { output: output.data }; } register< diff --git a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts index 31a89a31f3..a6926a2fd9 100644 --- a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts +++ b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts @@ -16,7 +16,6 @@ import { createRegisterCatalogEntitiesAction } from './createRegisterCatalogEntitiesAction'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; -import { ForwardedError } from '@backstage/errors'; describe('createRegisterCatalogEntitiesAction', () => { it('should successfully register a catalog location with a valid URL', async () => { @@ -78,14 +77,13 @@ describe('createRegisterCatalogEntitiesAction', () => { ).rejects.toThrow('not-a-valid-url is an invalid URL'); }); - it('should throw a ForwardedError if catalog.addLocation throws an error', async () => { + it('should throw the original error if catalog.addLocation fails', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); const mockCatalog = catalogServiceMock(); - const errorMessage = 'Failed to add location'; mockCatalog.addLocation = jest .fn() - .mockRejectedValue(new Error(errorMessage)); + .mockRejectedValue(new Error('Failed to add location')); createRegisterCatalogEntitiesAction({ catalog: mockCatalog, @@ -100,6 +98,9 @@ describe('createRegisterCatalogEntitiesAction', () => { 'https://github.com/example/repo/blob/main/catalog-info.yaml', }, }), - ).rejects.toThrow(ForwardedError); + ).rejects.toMatchObject({ + name: 'Error', + message: 'Failed to add location', + }); }); }); diff --git a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts index 492a427630..bd886e37ba 100644 --- a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts +++ b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts @@ -192,10 +192,13 @@ describe('createUnregisterCatalogEntitiesAction', () => { ); }); - it('should throw NotFoundError if no location matches the URL', async () => { + it('should throw NotFoundError with the original message if no location matches the URL', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); const mockCatalog = catalogServiceMock(); + const locationUrl = + 'https://github.com/backstage/demo/blob/master/catalog-info.yaml'; + mockCatalog.getLocations = jest.fn().mockResolvedValue({ items: [ { id: 'location-id-1', target: 'https://other-url.com/catalog.yaml' }, @@ -207,51 +210,24 @@ describe('createUnregisterCatalogEntitiesAction', () => { actionsRegistry: mockActionsRegistry, }); - await expect( - mockActionsRegistry.invoke({ - id: 'test:unregister-entity', - input: { - type: { - locationUrl: - 'https://github.com/backstage/demo/blob/master/catalog-info.yaml', - }, - }, - }), - ).rejects.toThrow(/NotFoundError/); - }); - - it('should throw NotFoundError with descriptive message when location not found', async () => { - const mockActionsRegistry = actionsRegistryServiceMock(); - const mockCatalog = catalogServiceMock(); - - const locationUrl = - 'https://github.com/backstage/demo/blob/master/catalog-info.yaml'; - - mockCatalog.getLocations = jest.fn().mockResolvedValue({ - items: [], - }); - - createUnregisterCatalogEntitiesAction({ - catalog: mockCatalog, - actionsRegistry: mockActionsRegistry, - }); - await expect( mockActionsRegistry.invoke({ id: 'test:unregister-entity', input: { type: { locationUrl } }, }), - ).rejects.toThrow(`Location with URL ${locationUrl} not found`); + ).rejects.toMatchObject({ + name: 'NotFoundError', + message: `Location with URL ${locationUrl} not found`, + }); }); - it('should throw an error if catalog.getLocations throws an error', async () => { + it('should throw the original error if catalog.getLocations fails', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); const mockCatalog = catalogServiceMock(); - const errorMessage = 'Failed to get locations'; mockCatalog.getLocations = jest .fn() - .mockRejectedValue(new Error(errorMessage)); + .mockRejectedValue(new Error('Failed to get locations')); createUnregisterCatalogEntitiesAction({ catalog: mockCatalog, @@ -268,7 +244,10 @@ describe('createUnregisterCatalogEntitiesAction', () => { }, }, }), - ).rejects.toThrow(errorMessage); + ).rejects.toMatchObject({ + name: 'Error', + message: 'Failed to get locations', + }); }); }); }); diff --git a/plugins/mcp-actions-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts index 038700c4a5..c75572bf20 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.test.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.test.ts @@ -26,6 +26,7 @@ import { CallToolResultSchema, ListToolsResultSchema, } from '@modelcontextprotocol/sdk/types.js'; +import { InputError, NotFoundError } from '@backstage/errors'; describe('McpService', () => { it('should list the available actions as tools in the mcp backend', async () => { @@ -343,4 +344,116 @@ describe('McpService', () => { }), ); }); + + it('should forward the original InputError when an action throws one', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + mockActionsRegistry.register({ + name: 'failing-action', + title: 'Failing', + description: 'An action that throws InputError', + schema: { + input: z => z.object({ value: z.string() }), + output: z => z.object({}), + }, + action: async () => { + throw new InputError('the value was invalid'); + }, + }); + + const mcpService = await McpService.create({ + actions: mockActionsRegistry, + metrics: metricsServiceMock.mock(), + }); + + const server = mcpService.getServer({ + credentials: mockCredentials.user(), + }); + + const client = new Client({ + name: 'test client', + version: '1.0', + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: 'tools/call', + params: { name: 'failing-action', arguments: { value: 'test' } }, + }, + CallToolResultSchema, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'InputError: the value was invalid', + }, + ], + isError: true, + }); + }); + + it('should forward the original NotFoundError when an action throws one', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + mockActionsRegistry.register({ + name: 'not-found-action', + title: 'Not Found', + description: 'An action that throws NotFoundError', + schema: { + input: z => z.object({ id: z.string() }), + output: z => z.object({}), + }, + action: async () => { + throw new NotFoundError('entity does not exist'); + }, + }); + + const mcpService = await McpService.create({ + actions: mockActionsRegistry, + metrics: metricsServiceMock.mock(), + }); + + const server = mcpService.getServer({ + credentials: mockCredentials.user(), + }); + + const client = new Client({ + name: 'test client', + version: '1.0', + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: 'tools/call', + params: { name: 'not-found-action', arguments: { id: 'abc' } }, + }, + CallToolResultSchema, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'NotFoundError: entity does not exist', + }, + ], + isError: true, + }); + }); }); diff --git a/plugins/mcp-actions-backend/src/services/handleErrors.test.ts b/plugins/mcp-actions-backend/src/services/handleErrors.test.ts new file mode 100644 index 0000000000..17fe6de0a6 --- /dev/null +++ b/plugins/mcp-actions-backend/src/services/handleErrors.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright 2025 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, + NotFoundError, + NotAllowedError, + ForwardedError, + ResponseError, +} from '@backstage/errors'; +import { handleErrors } from './handleErrors'; + +describe('handleErrors', () => { + it('should return the error description for a known error', async () => { + const result = await handleErrors(async () => { + throw new InputError('bad input'); + }); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'InputError: bad input' }], + isError: true, + }); + }); + + it('should return the error description for a NotFoundError', async () => { + const result = await handleErrors(async () => { + throw new NotFoundError('entity not found'); + }); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'NotFoundError: entity not found' }], + isError: true, + }); + }); + + it('should return the error description for a NotAllowedError', async () => { + const result = await handleErrors(async () => { + throw new NotAllowedError('forbidden'); + }); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'NotAllowedError: forbidden' }], + isError: true, + }); + }); + + it('should rethrow an unknown error', async () => { + await expect( + handleErrors(async () => { + throw new Error('unknown problem'); + }), + ).rejects.toThrow('unknown problem'); + }); + + it('should handle a ForwardedError that inherits the cause name', async () => { + const result = await handleErrors(async () => { + throw new ForwardedError( + 'wrapper message', + new InputError('original error'), + ); + }); + + // ForwardedError inherits name from cause and CustomErrorBase + // concatenates the cause message into the full message + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'InputError: wrapper message; caused by InputError: original error', + }, + ], + isError: true, + }); + }); + + it('should extract the cause from a ResponseError', async () => { + const response = { + ok: false, + status: 400, + statusText: 'Bad Request', + headers: new Headers({ 'content-type': 'application/json' }), + text: async () => + JSON.stringify({ + error: { name: 'InputError', message: 'bad value' }, + response: { statusCode: 400 }, + }), + }; + + const responseError = await ResponseError.fromResponse(response as any); + + const result = await handleErrors(async () => { + throw responseError; + }); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'InputError: bad value' }], + isError: true, + }); + }); + + it('should recursively extract through nested ResponseErrors', async () => { + const innerResponse = { + ok: false, + status: 400, + statusText: 'Bad Request', + headers: new Headers({ 'content-type': 'application/json' }), + text: async () => + JSON.stringify({ + error: { + name: 'ResponseError', + message: 'Request failed with 400 Bad Request', + cause: { name: 'InputError', message: 'deeply nested error' }, + }, + response: { statusCode: 400 }, + }), + }; + + const responseError = await ResponseError.fromResponse( + innerResponse as any, + ); + + const result = await handleErrors(async () => { + throw responseError; + }); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'InputError: deeply nested error' }], + isError: true, + }); + }); +}); diff --git a/plugins/mcp-actions-backend/src/services/handleErrors.ts b/plugins/mcp-actions-backend/src/services/handleErrors.ts index 5266c80e75..97070bb723 100644 --- a/plugins/mcp-actions-backend/src/services/handleErrors.ts +++ b/plugins/mcp-actions-backend/src/services/handleErrors.ts @@ -35,14 +35,14 @@ const knownErrors = new Set([ 'ServiceUnavailableError', ]); -// Extracts the cause error, if the provided error is `ResponseError` or -// `ForwardedError` with a cause. +// Recursively extracts the innermost cause from ResponseError or +// ForwardedError wrappers to surface the original error. function extractCause(err: ErrorLike): ErrorLike { if ( (err.name === 'ResponseError' || err instanceof ForwardedError) && isError(err.cause) ) { - return err.cause; + return extractCause(err.cause); } return err; } From 3eac7e204cb7bf092aac011e50547c03c43348ec Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 10:04:27 +0100 Subject: [PATCH 086/118] Revert contentOrder to defaultContentOrder, only sort within groups Keep ungrouped tabs in their natural order and only apply content ordering within actual groups. Rename the contentOrder prop back to defaultContentOrder to clarify its scope. Signed-off-by: Patrik Oldsberg --- plugins/catalog/report-alpha.api.md | 2 +- .../components/EntityLayout/EntityLayout.tsx | 6 +++--- .../alpha/components/EntityTabs/EntityTabs.tsx | 6 +++--- .../components/EntityTabs/EntityTabsList.tsx | 18 ++++++++++-------- plugins/catalog/src/alpha/pages.tsx | 2 +- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index 2c44d0cefc..3aae7d8920 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -1119,8 +1119,8 @@ const _default: OverridableFrontendPlugin< } >[] | undefined; - contentOrder?: 'title' | 'natural' | undefined; showNavItemIcons?: boolean | undefined; + contentOrder?: 'title' | 'natural' | undefined; title?: string | undefined; path?: string | undefined; }; diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx index ecee929cde..895f36150f 100644 --- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx @@ -80,7 +80,7 @@ export interface EntityLayoutProps { */ parentEntityRelations?: string[]; groupDefinitions: EntityContentGroupDefinitions; - contentOrder?: 'title' | 'natural'; + defaultContentOrder?: 'title' | 'natural'; showNavItemIcons?: boolean; } @@ -111,7 +111,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { NotFoundComponent, parentEntityRelations, groupDefinitions, - contentOrder, + defaultContentOrder, showNavItemIcons, } = props; const { kind } = useRouteRefParams(entityRouteRef); @@ -166,7 +166,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { )} diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx index 22ae70d726..102df76042 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx @@ -72,12 +72,12 @@ export function useSelectedSubRoute(subRoutes: SubRoute[]): { type EntityTabsProps = { routes: SubRoute[]; groupDefinitions: EntityContentGroupDefinitions; - contentOrder?: 'title' | 'natural'; + defaultContentOrder?: 'title' | 'natural'; showIcons?: boolean; }; export function EntityTabs(props: EntityTabsProps) { - const { routes, groupDefinitions, contentOrder, showIcons } = props; + const { routes, groupDefinitions, defaultContentOrder, showIcons } = props; const { index, route, element } = useSelectedSubRoute(routes); @@ -108,7 +108,7 @@ export function EntityTabs(props: EntityTabsProps) { selectedIndex={index} showIcons={showIcons} groupDefinitions={groupDefinitions} - contentOrder={contentOrder} + defaultContentOrder={defaultContentOrder} /> diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx index e55129c573..d647b52afb 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx @@ -78,7 +78,7 @@ type TabGroup = { type EntityTabsListProps = { tabs: Tab[]; groupDefinitions: EntityContentGroupDefinitions; - contentOrder?: 'title' | 'natural'; + defaultContentOrder?: 'title' | 'natural'; showIcons?: boolean; selectedIndex?: number; }; @@ -106,7 +106,7 @@ export function EntityTabsList(props: EntityTabsListProps) { selectedIndex = 0, showIcons, groupDefinitions, - contentOrder = 'title', + defaultContentOrder = 'title', } = props; const aliasToGroup = useMemo( @@ -157,16 +157,18 @@ export function EntityTabsList(props: EntityTabsListProps) { for (const [id, tabGroup] of sorted) { const groupDef = groupDefinitions[id]; - const order = groupDef?.contentOrder ?? contentOrder; - if (order === 'title') { - tabGroup.items.sort((a, b) => - a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }), - ); + if (groupDef) { + const order = groupDef.contentOrder ?? defaultContentOrder; + if (order === 'title') { + tabGroup.items.sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }), + ); + } } } return sorted; - }, [items, groupDefinitions, aliasToGroup, contentOrder]); + }, [items, groupDefinitions, aliasToGroup, defaultContentOrder]); const selectedItem = items[selectedIndex]; return ( diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index 61801478f7..4d37a18c98 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -178,7 +178,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ header={header} contextMenuItems={filteredMenuItems} groupDefinitions={groupDefinitions} - contentOrder={config.contentOrder} + defaultContentOrder={config.contentOrder} showNavItemIcons={config.showNavItemIcons} > {inputs.contents.map(output => ( From 3be516b73acb5d2d0fbeff9c603fa46cfd3bf2f4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 10:15:35 +0100 Subject: [PATCH 087/118] Document group aliases and content ordering configuration Add documentation for the new group aliases and contentOrder options to the catalog customization guide. Signed-off-by: Patrik Oldsberg --- .../config/vocabularies/Backstage/accept.txt | 1 + .../software-catalog/catalog-customization.md | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index f6f37b17cf..81ef949651 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -527,6 +527,7 @@ unassign unbreak Unconference undici +ungrouped unicode unmanaged unmount diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md index d62bb34505..5ddcb7d5a4 100644 --- a/docs/features/software-catalog/catalog-customization.md +++ b/docs/features/software-catalog/catalog-customization.md @@ -728,6 +728,49 @@ Notes: - Icons for groups and tabs are resolved via the app's IconsApi. When using a string icon id (for example `"dashboard"`), ensure that the corresponding icon bundles are enabled/installed in your app (see the [IconBundleBlueprint documentation](https://backstage.io/api/stable/variables/_backstage_plugin-app-react.IconBundleBlueprint.html)). - Group icons are only rendered if `showNavItemIcons` is set to `true`. +### Content ordering within groups + +By default, content items within each group are sorted alphabetically by title. You can change this with the `contentOrder` option, which supports two modes: + +- **`title`** (default) — sort alphabetically by the content extension's title (case-insensitive). +- **`natural`** — preserve the natural extension discovery/registration order. + +A page-level `contentOrder` sets the default for all groups, and individual groups can override it: + +```yaml +app: + extensions: + - page:catalog/entity: + config: + # Default content order for all groups + contentOrder: title + + groups: + - documentation: + title: Docs + # Override: keep natural order for this group + contentOrder: natural +``` + +Note that content ordering only applies to content items within groups. Ungrouped tabs (those not matching any group definition) always retain their natural order. + +### Group aliases + +Groups can declare `aliases` — a list of other group IDs that should be treated as equivalent. Any entity content extension targeting an aliased group ID will be included in the aliasing group. This is useful when renaming or merging groups without having to reconfigure individual extensions: + +```yaml +app: + extensions: + - page:catalog/entity: + config: + groups: + - develop: + title: Develop + # Content targeting 'development' will appear in this group + aliases: + - development +``` + ### Overriding or disabling a tab's group (per extension) Each entity content extension (tabs on the entity page) can declare a default `group` in code. You can override or disable this per installation in `app-config.yaml` using the extension's config: From 1b32ec6a8b4e9c4e60b4dc4d89f18735b25d25e7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 11:48:51 +0100 Subject: [PATCH 088/118] Resolve selected tab value through alias map When the selected tab uses an aliased group ID, resolve it through the alias map so the MUI Tabs selection indicator matches the rendered group key. Signed-off-by: Patrik Oldsberg --- .../src/alpha/components/EntityTabs/EntityTabsList.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx index d647b52afb..9abbfb69d9 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx @@ -171,6 +171,11 @@ export function EntityTabsList(props: EntityTabsListProps) { }, [items, groupDefinitions, aliasToGroup, defaultContentOrder]); const selectedItem = items[selectedIndex]; + const selectedGroup = resolveGroupId( + selectedItem?.group, + groupDefinitions, + aliasToGroup, + ); return ( {groups.map(([id, tabGroup]) => ( Date: Tue, 24 Feb 2026 21:15:41 +0100 Subject: [PATCH 089/118] run vale directly instead of through the action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .github/workflows/verify_docs-quality.yml | 32 +++++---- scripts/check-docs-quality.js | 86 ++++++++++++++++++++++- 2 files changed, 102 insertions(+), 16 deletions(-) diff --git a/.github/workflows/verify_docs-quality.yml b/.github/workflows/verify_docs-quality.yml index af42f25390..7aa855e189 100644 --- a/.github/workflows/verify_docs-quality.yml +++ b/.github/workflows/verify_docs-quality.yml @@ -7,7 +7,7 @@ on: - '**.md' jobs: - check-all-files: + check-docs: runs-on: ubuntu-latest steps: @@ -18,18 +18,20 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - # Vale does not support file excludes, so we use the script to generate a list of files instead - # The action also does not allow args or a local config file to be passed in, so the files array - # also contains an "--config=.github/vale/config.ini" option - - name: generate vale args - id: generate - run: echo "args=$(node scripts/check-docs-quality.js --ci-args)" >> $GITHUB_OUTPUT - - - name: documentation quality check - uses: errata-ai/vale-action@d89dee975228ae261d22c15adcd03578634d429c # v2.1.1 - with: - # This also contains --config=.github/vale/config.ini ... :/ - files: '${{ steps.generate.outputs.args }}' - version: latest + - name: Install vale + run: | + gh release download --repo errata-ai/vale -p 'vale_*_Linux_64-bit.tar.gz' -D /tmp + tar -xzf /tmp/vale_*_Linux_64-bit.tar.gz -C /usr/local/bin vale + vale --version env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} + + - name: Get changed files + run: | + gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ + --paginate -q '.[] | select(.status != "removed") | .filename' > /tmp/pr-files.txt + env: + GH_TOKEN: ${{ github.token }} + + - name: Documentation quality check + run: node scripts/check-docs-quality.js --ci /tmp/pr-files.txt diff --git a/scripts/check-docs-quality.js b/scripts/check-docs-quality.js index fbedcfe077..4769496ba2 100755 --- a/scripts/check-docs-quality.js +++ b/scripts/check-docs-quality.js @@ -71,7 +71,7 @@ async function exitIfMissingVale() { try { // eslint-disable-next-line @backstage/no-undeclared-imports await require('command-exists')('vale'); - } catch (e) { + } catch { console.log( `Language linter (vale) was not found. Please install vale linter (https://vale.sh/docs/vale-cli/installation/).\n`, ); @@ -101,7 +101,91 @@ async function runVale(files) { return true; } +async function ciCheck(prFilesPath) { + const content = await fs.readFile(prFilesPath, 'utf8'); + const prFiles = content.split('\n').filter(f => f.trim()); + + const mdFiles = prFiles + .filter(f => f.endsWith('.md')) + .filter(f => !IGNORED_WHEN_LISTING.some(p => p.test(f))); + + if (mdFiles.length === 0) { + console.log('No documentation files to check.'); + return; + } + + console.log(`Checking ${mdFiles.length} changed documentation file(s)...`); + + const result = spawnSync( + 'vale', + [ + '--config', + resolvePath(rootDir, '.vale.ini'), + '--output=JSON', + ...mdFiles, + ], + { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 }, + ); + + if (result.error) { + console.error('Failed to run vale:', result.error.message); + process.exit(1); + } + + let issueCount = 0; + + if (result.stdout && result.stdout.trim()) { + try { + const data = JSON.parse(result.stdout); + for (const [file, alerts] of Object.entries(data)) { + for (const alert of alerts) { + const severityLevels = { + error: 'error', + warning: 'warning', + }; + const level = severityLevels[alert.Severity] ?? 'notice'; + const col = alert.Span ? alert.Span[0] : 1; + const endCol = alert.Span ? `,endColumn=${alert.Span[1] + 1}` : ''; + console.log( + `::${level} file=${file},line=${alert.Line},col=${col}${endCol},title=${alert.Check}::${alert.Message}`, + ); + issueCount++; + } + } + } catch { + console.error('Failed to parse vale output:'); + console.error(result.stdout); + process.exit(1); + } + } + + if (result.stderr && result.stderr.trim()) { + console.error(result.stderr); + } + + if (issueCount > 0) { + console.log( + `\nFound ${issueCount} documentation quality issue(s). Please review the annotations above.`, + ); + } + + if (result.status !== 0) { + process.exit(1); + } +} + async function main() { + if (process.argv.includes('--ci')) { + const idx = process.argv.indexOf('--ci'); + const prFilesPath = process.argv[idx + 1]; + if (!prFilesPath) { + console.error('Usage: check-docs-quality.js --ci '); + process.exit(1); + } + await ciCheck(prFilesPath); + return; + } + if (process.argv.includes('--ci-args')) { const files = await listFiles(); From 2690d416f8b73c748597cbba3783ded5babedab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 24 Feb 2026 21:30:27 +0100 Subject: [PATCH 090/118] Update scripts/check-docs-quality.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Fredrik Adelöw --- scripts/check-docs-quality.js | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/check-docs-quality.js b/scripts/check-docs-quality.js index 4769496ba2..057d4580df 100755 --- a/scripts/check-docs-quality.js +++ b/scripts/check-docs-quality.js @@ -142,6 +142,7 @@ async function ciCheck(prFilesPath) { const severityLevels = { error: 'error', warning: 'warning', + suggestion: 'notice', }; const level = severityLevels[alert.Severity] ?? 'notice'; const col = alert.Span ? alert.Span[0] : 1; From 0c0f46de09f85d8a40098393a781031a0bc301dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 24 Feb 2026 21:30:33 +0100 Subject: [PATCH 091/118] Update .github/workflows/verify_docs-quality.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Fredrik Adelöw --- .github/workflows/verify_docs-quality.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/verify_docs-quality.yml b/.github/workflows/verify_docs-quality.yml index 7aa855e189..6f9798169e 100644 --- a/.github/workflows/verify_docs-quality.yml +++ b/.github/workflows/verify_docs-quality.yml @@ -21,7 +21,9 @@ jobs: - name: Install vale run: | gh release download --repo errata-ai/vale -p 'vale_*_Linux_64-bit.tar.gz' -D /tmp - tar -xzf /tmp/vale_*_Linux_64-bit.tar.gz -C /usr/local/bin vale + mkdir -p "$HOME/.local/bin" + tar -xzf /tmp/vale_*_Linux_64-bit.tar.gz -C "$HOME/.local/bin" vale + echo "$HOME/.local/bin" >> "$GITHUB_PATH" vale --version env: GH_TOKEN: ${{ github.token }} From 356d1662d833989327e9ccde0773ab8673d9b70d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 24 Feb 2026 21:40:56 +0100 Subject: [PATCH 092/118] Update .github/workflows/verify_docs-quality.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Fredrik Adelöw --- .github/workflows/verify_docs-quality.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/verify_docs-quality.yml b/.github/workflows/verify_docs-quality.yml index 6f9798169e..1989b39814 100644 --- a/.github/workflows/verify_docs-quality.yml +++ b/.github/workflows/verify_docs-quality.yml @@ -8,6 +8,9 @@ on: jobs: check-docs: + permissions: + contents: read + pull-requests: read runs-on: ubuntu-latest steps: From 4e85e7b0b3692316075d12ff9d5f07df227b5290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 24 Feb 2026 21:42:10 +0100 Subject: [PATCH 093/118] Update .github/workflows/verify_docs-quality.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Fredrik Adelöw --- .github/workflows/verify_docs-quality.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/verify_docs-quality.yml b/.github/workflows/verify_docs-quality.yml index 1989b39814..ef8bb40d18 100644 --- a/.github/workflows/verify_docs-quality.yml +++ b/.github/workflows/verify_docs-quality.yml @@ -23,9 +23,21 @@ jobs: - name: Install vale run: | - gh release download --repo errata-ai/vale -p 'vale_*_Linux_64-bit.tar.gz' -D /tmp + VALE_VERSION="3.7.1" + VALE_REPO="errata-ai/vale" + VALE_DIST_DIR="/tmp/vale-dist" + + mkdir -p "$VALE_DIST_DIR" + + # Download pinned Vale binary and corresponding checksums file + gh release download "v${VALE_VERSION}" --repo "$VALE_REPO" --pattern "vale_${VALE_VERSION}_Linux_64-bit.tar.gz" --dir "$VALE_DIST_DIR" + gh release download "v${VALE_VERSION}" --repo "$VALE_REPO" --pattern "vale_${VALE_VERSION}_checksums.txt" --dir "$VALE_DIST_DIR" + + # Verify the integrity of the downloaded archive + (cd "$VALE_DIST_DIR" && sha256sum --check --ignore-missing "vale_${VALE_VERSION}_checksums.txt") + mkdir -p "$HOME/.local/bin" - tar -xzf /tmp/vale_*_Linux_64-bit.tar.gz -C "$HOME/.local/bin" vale + tar -xzf "$VALE_DIST_DIR/vale_${VALE_VERSION}_Linux_64-bit.tar.gz" -C "$HOME/.local/bin" vale echo "$HOME/.local/bin" >> "$GITHUB_PATH" vale --version env: From a6b28199e541da0819e2ce63ad53f04ac102f0e5 Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Thu, 26 Feb 2026 14:45:10 +0100 Subject: [PATCH 094/118] `feat(catalog)`: Implement `query-catalog-entities` on top of the filter predicates (#33022) * chore: create query catalog Signed-off-by: benjdlambert Signed-off-by: benjdlambert * feat(catalog-backend): add query-catalog-entities action Signed-off-by: benjdlambert * fix: address PR review feedback Signed-off-by: benjdlambert --------- Signed-off-by: benjdlambert --- .../add-query-catalog-entities-action.md | 5 + .../createQueryCatalogEntitiesAction.test.ts | 344 ++++++++++++++++++ .../createQueryCatalogEntitiesAction.ts | 220 +++++++++++ plugins/catalog-backend/src/actions/index.ts | 2 + 4 files changed, 571 insertions(+) create mode 100644 .changeset/add-query-catalog-entities-action.md create mode 100644 plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.test.ts create mode 100644 plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.ts diff --git a/.changeset/add-query-catalog-entities-action.md b/.changeset/add-query-catalog-entities-action.md new file mode 100644 index 0000000000..78fb30d584 --- /dev/null +++ b/.changeset/add-query-catalog-entities-action.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Added `query-catalog-entities` action to the catalog backend actions registry. Supports predicate-based filtering with `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators. diff --git a/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.test.ts b/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.test.ts new file mode 100644 index 0000000000..3be032a161 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.test.ts @@ -0,0 +1,344 @@ +/* + * Copyright 2025 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 { createQueryCatalogEntitiesAction } from './createQueryCatalogEntitiesAction'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; + +const testEntities = [ + { + kind: 'Component', + apiVersion: 'backstage.io/v1alpha1', + metadata: { + name: 'service-a', + namespace: 'default', + annotations: { 'backstage.io/techdocs-ref': 'dir:.' }, + }, + spec: { + type: 'service', + dependsOn: ['component:default/shared-lib', 'api:default/user-api'], + }, + }, + { + kind: 'Component', + apiVersion: 'backstage.io/v1alpha1', + metadata: { name: 'website-b', namespace: 'default' }, + spec: { type: 'website' }, + }, + { + kind: 'API', + apiVersion: 'backstage.io/v1alpha1', + metadata: { name: 'user-api', namespace: 'default' }, + spec: { type: 'openapi' }, + }, + { + kind: 'Group', + apiVersion: 'backstage.io/v1alpha1', + metadata: { name: 'team-alpha', namespace: 'default' }, + }, +]; + +function createCatalogQueryAction(options?: { + entities?: typeof testEntities; +}) { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock({ + entities: options?.entities ?? testEntities, + }); + createQueryCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + return { invoke: mockActionsRegistry.invoke.bind(mockActionsRegistry) }; +} + +describe('createQueryCatalogEntitiesAction', () => { + it('should return all entities when no filter is provided', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: {}, + }); + + expect(result.output).toEqual({ + items: testEntities, + totalItems: 4, + hasMoreEntities: false, + nextPageCursor: undefined, + }); + }); + + it('should return empty results when no entities match', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { query: { kind: 'NonExistent' } }, + }); + + expect(result.output).toEqual({ + items: [], + totalItems: 0, + hasMoreEntities: false, + nextPageCursor: undefined, + }); + }); + + it('should filter by kind', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { query: { kind: 'Component' } }, + }); + + expect(result.output).toMatchObject({ + totalItems: 2, + items: expect.arrayContaining([ + expect.objectContaining({ + metadata: { + name: 'service-a', + namespace: 'default', + annotations: { 'backstage.io/techdocs-ref': 'dir:.' }, + }, + }), + expect.objectContaining({ + metadata: { name: 'website-b', namespace: 'default' }, + }), + ]), + }); + }); + + it('should filter with multiple conditions', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { kind: 'Component', 'spec.type': 'service' }, + }, + }); + + expect(result.output).toMatchObject({ + totalItems: 1, + items: [ + expect.objectContaining({ + metadata: expect.objectContaining({ name: 'service-a' }), + }), + ], + }); + }); + + it('should support $not operator', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { query: { $not: { kind: 'Group' } } }, + }); + + expect(result.output).toMatchObject({ totalItems: 3 }); + const items = (result.output as any).items; + expect(items.every((e: any) => e.kind !== 'Group')).toBe(true); + }); + + it('should support $all operator', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { + $all: [{ kind: 'Component' }, { 'spec.type': 'service' }], + }, + }, + }); + + expect(result.output).toMatchObject({ + totalItems: 1, + items: [ + expect.objectContaining({ + metadata: expect.objectContaining({ name: 'service-a' }), + }), + ], + }); + }); + + it('should support $any operator', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { + $any: [{ kind: 'API' }, { kind: 'Group' }], + }, + }, + }); + + expect(result.output).toMatchObject({ totalItems: 2 }); + const items = (result.output as any).items; + expect( + items.every((e: any) => e.kind === 'API' || e.kind === 'Group'), + ).toBe(true); + }); + + it('should support $exists: true', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { + 'metadata.annotations.backstage.io/techdocs-ref': { $exists: true }, + }, + }, + }); + + expect(result.output).toMatchObject({ + totalItems: 1, + items: [ + expect.objectContaining({ + metadata: expect.objectContaining({ name: 'service-a' }), + }), + ], + }); + }); + + it('should support $exists: false', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { + 'metadata.annotations.backstage.io/techdocs-ref': { $exists: false }, + }, + }, + }); + + expect(result.output).toMatchObject({ totalItems: 3 }); + const items = (result.output as any).items; + expect(items.every((e: any) => e.metadata.name !== 'service-a')).toBe(true); + }); + + it('should support $in operator', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { 'spec.type': { $in: ['service', 'openapi'] } }, + }, + }); + + expect(result.output).toMatchObject({ totalItems: 2 }); + const names = (result.output as any).items.map((e: any) => e.metadata.name); + expect(names).toEqual(expect.arrayContaining(['service-a', 'user-api'])); + }); + + it('should support $contains operator', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { + 'spec.dependsOn': { $contains: 'component:default/shared-lib' }, + }, + }, + }); + + expect(result.output).toMatchObject({ + totalItems: 1, + items: [ + expect.objectContaining({ + metadata: expect.objectContaining({ name: 'service-a' }), + }), + ], + }); + }); + + it('should support pagination with limit', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { limit: 2 }, + }); + + const output = result.output as any; + expect(output.items).toHaveLength(2); + expect(output.totalItems).toBe(4); + expect(output.hasMoreEntities).toBe(true); + expect(output.nextPageCursor).toBeDefined(); + }); + + it('should support cursor-based pagination', async () => { + const { invoke } = createCatalogQueryAction(); + + const firstPage = await invoke({ + id: 'test:query-catalog-entities', + input: { limit: 2 }, + }); + const firstOutput = firstPage.output as any; + + const secondPage = await invoke({ + id: 'test:query-catalog-entities', + input: { cursor: firstOutput.nextPageCursor, limit: 2 }, + }); + const secondOutput = secondPage.output as any; + + expect(secondOutput.items).toHaveLength(2); + expect(secondOutput.hasMoreEntities).toBe(false); + }); + + it('should support orderFields', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { kind: 'Component' }, + orderFields: { field: 'metadata.name', order: 'desc' }, + }, + }); + + const names = (result.output as any).items.map((e: any) => e.metadata.name); + expect(names).toEqual(['website-b', 'service-a']); + }); + + it('should support array orderFields for multi-field sorting', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + orderFields: [ + { field: 'kind', order: 'asc' }, + { field: 'metadata.name', order: 'asc' }, + ], + }, + }); + + const kinds = (result.output as any).items.map((e: any) => e.kind); + expect(kinds).toEqual(['API', 'Component', 'Component', 'Group']); + }); + + it('should support fields projection', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { kind: 'Component', 'spec.type': 'service' }, + fields: ['kind', 'metadata.name'], + }, + }); + + const items = (result.output as any).items; + expect(items).toHaveLength(1); + expect(items[0]).toEqual({ + kind: 'Component', + metadata: { name: 'service-a' }, + }); + }); +}); diff --git a/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.ts b/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.ts new file mode 100644 index 0000000000..6bf4171163 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.ts @@ -0,0 +1,220 @@ +/* + * Copyright 2025 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 { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { CatalogService } from '@backstage/plugin-catalog-node'; +import { createZodV3FilterPredicateSchema } from '@backstage/filter-predicates'; + +export const createQueryCatalogEntitiesAction = ({ + catalog, + actionsRegistry, +}: { + catalog: CatalogService; + actionsRegistry: ActionsRegistryService; +}) => { + actionsRegistry.register({ + name: 'query-catalog-entities', + title: 'Query Catalog Entities', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, + description: ` +Query entities from the Backstage Software Catalog using predicate filters. + +## Catalog Model + +The catalog contains entities of different kinds. Every entity has "kind", "apiVersion", "metadata", and optionally "spec" and "relations". Fields use dot notation for querying. + +Common metadata fields on all entities: name, namespace (default: "default"), title, description, labels, annotations, tags (string array), links. + +Entity references use the format "kind:namespace/name", e.g. "component:default/my-service" or "user:default/jane.doe". + +### Entity Kinds + +**Component** - A piece of software such as a service, website, or library. + spec fields: type (e.g. "service", "website", "library"), lifecycle (e.g. "production", "experimental", "deprecated"), owner (entity ref), system, subcomponentOf, providesApis, consumesApis, dependsOn, dependencyOf. + +**API** - An interface that components expose, such as REST APIs or event streams. + spec fields: type (e.g. "openapi", "asyncapi", "graphql", "grpc"), lifecycle, owner (entity ref), definition (the API spec content), system. + +**System** - A collection of components, APIs, and resources that together expose some functionality. + spec fields: owner (entity ref), domain, type. + +**Domain** - A grouping of systems that share terminology, domain models, and business purpose. + spec fields: owner (entity ref), subdomainOf, type. + +**Resource** - Infrastructure required to operate a component, such as databases or storage buckets. + spec fields: type, owner (entity ref), system, dependsOn, dependencyOf. + +**Group** - An organizational entity such as a team or business unit. + spec fields: type (e.g. "team", "business-unit"), children (entity refs), parent (entity ref), members (entity refs), profile (displayName, email, picture). + +**User** - A person, such as an employee or contractor. + spec fields: memberOf (entity refs), profile (displayName, email, picture). + +**Location** - A marker that references other catalog descriptor files to be ingested. + spec fields: type, target, targets, presence. + +### Relations + +Entities have bidirectional relations stored in the "relations" array. Common relation types: ownedBy/ownerOf, dependsOn/dependencyOf, providesApi/apiProvidedBy, consumesApi/apiConsumedBy, parentOf/childOf, memberOf/hasMember, partOf/hasPart. + +Relations can be queried via "relations." e.g. "relations.ownedby: user:default/jane-doe". The value there must always be a valid entity reference. + +When querying for entity relationships, prefer using relations over spec fields. For example, use "relations.ownedby" instead of "spec.owner" to find entities owned by a particular group or user. + +## Query Syntax + +The query uses predicate expressions with dot-notation field paths. + +Simple matching: + { query: { kind: "Component" } } + { query: { kind: "Component", "spec.type": "service" } } + +Value operators: + { query: { kind: { "$in": ["API", "Component"] } } } + { query: { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } } } + { query: { "metadata.tags": { "$contains": "java" } } } + { query: { "metadata.name": { "$hasPrefix": "team-" } } } + +Logical operators: + { query: { "$all": [{ kind: "Component" }, { "spec.lifecycle": "production" }] } } + { query: { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] } } + { query: { "$not": { kind: "Group" } } } + +Querying relations - find all entities owned by a specific group: + { query: { "relations.ownedby": "group:default/team-alpha" } } + +Combined example - find production services or websites with TechDocs: + { query: { "$all": [ + { kind: "Component", "spec.lifecycle": "production" }, + { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] }, + { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } } + ] } } + +## Other Options + +Limit returned fields: { fields: ["kind", "metadata.name", "metadata.namespace"] } +Sort results: { orderFields: { field: "metadata.name", order: "asc" } } +Full text search: { fullTextFilter: { term: "auth", fields: ["metadata.name", "metadata.title"] } } +Pagination: Use limit (e.g. 20) and the returned nextPageCursor for subsequent requests via cursor. + `, + schema: { + input: z => + z.object({ + query: createZodV3FilterPredicateSchema(z) + .optional() + .describe( + 'Entity predicate query. Supports field matching, $all, $any, $not, $exists, $in, $contains, and $hasPrefix operators.', + ), + fields: z + .array(z.string()) + .optional() + .describe( + 'Specific fields to include in the response. If not provided, all fields are returned. Each entry is a dot separated path into an entity, e.g. `spec.type`.', + ), + limit: z + .number() + .int() + .positive() + .optional() + .describe('Maximum number of entities to return at a time.'), + offset: z + .number() + .int() + .min(0) + .optional() + .describe('Number of entities to skip before returning results.'), + orderFields: z + .union([ + z.object({ + field: z + .string() + .describe( + 'Field to order by. The format is a dot separated path into an entity, e.g. `spec.type`.', + ), + order: z.enum(['asc', 'desc']).describe('Sort order'), + }), + z.array( + z.object({ + field: z + .string() + .describe( + 'Field to order by. The format is a dot separated path into an entity, e.g. `spec.type`.', + ), + order: z.enum(['asc', 'desc']).describe('Sort order'), + }), + ), + ]) + .optional() + .describe( + 'Ordering criteria for the results. Can be a single order directive or an array for multi-field sorting.', + ), + fullTextFilter: z + .object({ + term: z.string().describe('Full text search term'), + fields: z + .array(z.string()) + .optional() + .describe( + 'Fields to search within. Each entry is a dot separated path into an entity, e.g. `spec.type`.', + ), + }) + .optional() + .describe('Full text search criteria'), + cursor: z + .string() + .optional() + .describe( + 'Cursor for pagination. This can be used only after the first request with a response containing a cursor. If a cursor is given it takes precedence over `offset`.', + ), + }), + output: z => + z.object({ + items: z + .array(z.object({}).passthrough()) + .describe('List of entities'), + totalItems: z.number().describe('Total number of entities'), + hasMoreEntities: z + .boolean() + .describe('Whether more entities are available'), + nextPageCursor: z + .string() + .optional() + .describe('Next page cursor used to fetch next page of entities'), + }), + }, + action: async ({ input, credentials }) => { + const response = await catalog.queryEntities( + { + ...input, + query: input.query, + }, + { credentials }, + ); + + return { + output: { + items: response.items, + totalItems: response.totalItems, + hasMoreEntities: !!response.pageInfo.nextCursor, + nextPageCursor: response.pageInfo.nextCursor, + }, + }; + }, + }); +}; diff --git a/plugins/catalog-backend/src/actions/index.ts b/plugins/catalog-backend/src/actions/index.ts index 5940bc32f1..04587c1a4e 100644 --- a/plugins/catalog-backend/src/actions/index.ts +++ b/plugins/catalog-backend/src/actions/index.ts @@ -19,6 +19,7 @@ import { createGetCatalogEntityAction } from './createGetCatalogEntityAction.ts' import { createValidateEntityAction } from './createValidateEntityAction.ts'; import { createRegisterCatalogEntitiesAction } from './createRegisterCatalogEntitiesAction.ts'; import { createUnregisterCatalogEntitiesAction } from './createUnregisterCatalogEntitiesAction.ts'; +import { createQueryCatalogEntitiesAction } from './createQueryCatalogEntitiesAction.ts'; export const createCatalogActions = (options: { actionsRegistry: ActionsRegistryService; @@ -28,4 +29,5 @@ export const createCatalogActions = (options: { createValidateEntityAction(options); createRegisterCatalogEntitiesAction(options); createUnregisterCatalogEntitiesAction(options); + createQueryCatalogEntitiesAction(options); }; From a22c0a368e6bb5a89ad669034ba0390a94a9243d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 14:44:02 +0100 Subject: [PATCH 095/118] use a distinct hash instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .github/workflows/verify_docs-quality.yml | 12 ++++++------ scripts/check-docs-quality.js | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/verify_docs-quality.yml b/.github/workflows/verify_docs-quality.yml index ef8bb40d18..6ace7d72a3 100644 --- a/.github/workflows/verify_docs-quality.yml +++ b/.github/workflows/verify_docs-quality.yml @@ -24,20 +24,20 @@ jobs: - name: Install vale run: | VALE_VERSION="3.7.1" - VALE_REPO="errata-ai/vale" + VALE_CHECKSUM="ba4924bf2c5884499f09b02a6eb3318b29df40a3e81701c0804b9b1aefcd9483" VALE_DIST_DIR="/tmp/vale-dist" + VALE_ARCHIVE="vale_${VALE_VERSION}_Linux_64-bit.tar.gz" mkdir -p "$VALE_DIST_DIR" - # Download pinned Vale binary and corresponding checksums file - gh release download "v${VALE_VERSION}" --repo "$VALE_REPO" --pattern "vale_${VALE_VERSION}_Linux_64-bit.tar.gz" --dir "$VALE_DIST_DIR" - gh release download "v${VALE_VERSION}" --repo "$VALE_REPO" --pattern "vale_${VALE_VERSION}_checksums.txt" --dir "$VALE_DIST_DIR" + # Download pinned Vale binary + curl -fsSL -H "Authorization: token $GH_TOKEN" "https://github.com/errata-ai/vale/releases/download/v${VALE_VERSION}/${VALE_ARCHIVE}" -o "$VALE_DIST_DIR/$VALE_ARCHIVE" # Verify the integrity of the downloaded archive - (cd "$VALE_DIST_DIR" && sha256sum --check --ignore-missing "vale_${VALE_VERSION}_checksums.txt") + echo "$VALE_CHECKSUM $VALE_DIST_DIR/$VALE_ARCHIVE" | sha256sum --check mkdir -p "$HOME/.local/bin" - tar -xzf "$VALE_DIST_DIR/vale_${VALE_VERSION}_Linux_64-bit.tar.gz" -C "$HOME/.local/bin" vale + tar -xzf "$VALE_DIST_DIR/$VALE_ARCHIVE" -C "$HOME/.local/bin" vale echo "$HOME/.local/bin" >> "$GITHUB_PATH" vale --version env: diff --git a/scripts/check-docs-quality.js b/scripts/check-docs-quality.js index 057d4580df..06383539c3 100755 --- a/scripts/check-docs-quality.js +++ b/scripts/check-docs-quality.js @@ -36,6 +36,7 @@ const IGNORED_WHEN_LISTING = [ const IGNORED_WHEN_EXPLICIT = [ /^ADOPTERS\.md$/, /^OWNERS\.md$/, + /^.*[/\\]CHANGELOG\.md$/, // generated from changesets anyway - THOSE should have been checked earlier /^.*[/\\]knip-report\.md$/, ]; 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 096/118] 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 097/118] 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 098/118] 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 099/118] 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 100/118] 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 101/118] 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 102/118] 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 103/118] 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 104/118] 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 105/118] 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 afabb37104c358ad8799a83c3f2d960b58a1012e Mon Sep 17 00:00:00 2001 From: thomvaill Date: Thu, 26 Feb 2026 16:50:57 +0100 Subject: [PATCH 106/118] fix(devtools): urlencode task IDs when calling trigger route Signed-off-by: thomvaill --- .changeset/cold-dodos-think.md | 5 ++ .../devtools/src/api/DevToolsClient.test.ts | 49 +++++++++++++++++++ plugins/devtools/src/api/DevToolsClient.ts | 2 +- 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 .changeset/cold-dodos-think.md create mode 100644 plugins/devtools/src/api/DevToolsClient.test.ts diff --git a/.changeset/cold-dodos-think.md b/.changeset/cold-dodos-think.md new file mode 100644 index 0000000000..021e5f679e --- /dev/null +++ b/.changeset/cold-dodos-think.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-devtools': patch +--- + +Fixed URL encoding of task IDs for the trigger feature (tasks that contained a "/" in their ID were not triggered) diff --git a/plugins/devtools/src/api/DevToolsClient.test.ts b/plugins/devtools/src/api/DevToolsClient.test.ts new file mode 100644 index 0000000000..d1972cd528 --- /dev/null +++ b/plugins/devtools/src/api/DevToolsClient.test.ts @@ -0,0 +1,49 @@ +/* + * 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 { DevToolsClient } from './DevToolsClient'; + +describe('DevToolsClient', () => { + const mockBaseUrl = 'http://backstage/api/catalog'; + const discoveryApi = { + getBaseUrl: async (pluginId: string) => `${mockBaseUrl}/${pluginId}`, + }; + const mockFetch = jest.fn(); + const fetchApi = { fetch: mockFetch }; + + let client: DevToolsClient; + beforeEach(() => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ status: 'triggered' }), + }); + client = new DevToolsClient({ discoveryApi, fetchApi }); + }); + + afterEach(() => jest.resetAllMocks()); + + it('should URL-encode the taskId when triggering a scheduled task', async () => { + await client.triggerScheduledTask( + 'my-plugin', + 'task/with/slashes:and-special-chars', + ); + + expect(mockFetch).toHaveBeenCalledWith( + `${mockBaseUrl}/my-plugin/.backstage/scheduler/v1/tasks/task%2Fwith%2Fslashes%3Aand-special-chars/trigger`, + { method: 'POST' }, + ); + }); +}); diff --git a/plugins/devtools/src/api/DevToolsClient.ts b/plugins/devtools/src/api/DevToolsClient.ts index cd4f53fa81..0577757838 100644 --- a/plugins/devtools/src/api/DevToolsClient.ts +++ b/plugins/devtools/src/api/DevToolsClient.ts @@ -70,7 +70,7 @@ export class DevToolsClient implements DevToolsApi { ): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl(plugin)}/`; const url = new URL( - `.backstage/scheduler/v1/tasks/${taskId}/trigger`, + `.backstage/scheduler/v1/tasks/${encodeURIComponent(taskId)}/trigger`, baseUrl, ); 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 107/118] 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 108/118] 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, ), From 004b5c120897bf7deef5088f0aa7e95113a0feed Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 18:06:57 +0100 Subject: [PATCH 109/118] Move formFieldsApiRef and ScaffolderFormFieldsApi to scaffolder-react The formFieldsApiRef and ScaffolderFormFieldsApi type are now defined and exported from @backstage/plugin-scaffolder-react/alpha, and re-exported from @backstage/plugin-scaffolder/alpha for backwards compatibility. Signed-off-by: Patrik Oldsberg --- .changeset/fix-form-fields-api-export-react.md | 5 +++++ .../fix-form-fields-api-export-scaffolder.md | 5 +++++ plugins/scaffolder-react/report-alpha.api.md | 10 ++++++++++ plugins/scaffolder-react/src/next/api/index.ts | 10 +++++++++- plugins/scaffolder-react/src/next/api/types.ts | 5 +++++ plugins/scaffolder/report-alpha.api.md | 11 ++++------- plugins/scaffolder/src/alpha/extensions.tsx | 6 ++++-- plugins/scaffolder/src/alpha/formFieldsApi.ts | 18 +++++------------- 8 files changed, 47 insertions(+), 23 deletions(-) create mode 100644 .changeset/fix-form-fields-api-export-react.md create mode 100644 .changeset/fix-form-fields-api-export-scaffolder.md diff --git a/.changeset/fix-form-fields-api-export-react.md b/.changeset/fix-form-fields-api-export-react.md new file mode 100644 index 0000000000..ff6d260e0f --- /dev/null +++ b/.changeset/fix-form-fields-api-export-react.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': minor +--- + +Added `formFieldsApiRef` and `ScaffolderFormFieldsApi` as alpha exports. diff --git a/.changeset/fix-form-fields-api-export-scaffolder.md b/.changeset/fix-form-fields-api-export-scaffolder.md new file mode 100644 index 0000000000..8ad31c5593 --- /dev/null +++ b/.changeset/fix-form-fields-api-export-scaffolder.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +The `formFieldsApiRef` and `ScaffolderFormFieldsApi` alpha exports are now re-exported from `@backstage/plugin-scaffolder-react`. diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md index 1b6fb99838..c546f265be 100644 --- a/plugins/scaffolder-react/report-alpha.api.md +++ b/plugins/scaffolder-react/report-alpha.api.md @@ -5,6 +5,7 @@ ```ts import { AnyApiRef } from '@backstage/core-plugin-api'; import { ApiHolder } from '@backstage/core-plugin-api'; +import { ApiRef } from '@backstage/frontend-plugin-api'; import { ComponentType } from 'react'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { CustomFieldValidator } from '@backstage/plugin-scaffolder-react'; @@ -198,6 +199,9 @@ export type FormFieldExtensionData< schema?: FieldSchema, z.output>; }; +// @alpha (undocumented) +export const formFieldsApiRef: ApiRef; + // @alpha (undocumented) export type FormValidation = { [name: string]: FieldValidation | FormValidation; @@ -272,6 +276,12 @@ export type ScaffolderFormDecoratorContext< ) => void; }; +// @alpha (undocumented) +export interface ScaffolderFormFieldsApi { + // (undocumented) + loadFormFields(): Promise; +} + // @alpha (undocumented) export function ScaffolderPageContextMenu( props: ScaffolderPageContextMenuProps, diff --git a/plugins/scaffolder-react/src/next/api/index.ts b/plugins/scaffolder-react/src/next/api/index.ts index 9988f4dd82..1ecddeaf41 100644 --- a/plugins/scaffolder-react/src/next/api/index.ts +++ b/plugins/scaffolder-react/src/next/api/index.ts @@ -14,4 +14,12 @@ * limitations under the License. */ -export { type FormField } from './types'; +import { createApiRef } from '@backstage/frontend-plugin-api'; +import { ScaffolderFormFieldsApi } from './types'; + +export { type FormField, type ScaffolderFormFieldsApi } from './types'; + +/** @alpha */ +export const formFieldsApiRef = createApiRef({ + id: 'plugin.scaffolder.form-fields-loader', +}); diff --git a/plugins/scaffolder-react/src/next/api/types.ts b/plugins/scaffolder-react/src/next/api/types.ts index 38929ee189..e3ab855708 100644 --- a/plugins/scaffolder-react/src/next/api/types.ts +++ b/plugins/scaffolder-react/src/next/api/types.ts @@ -18,3 +18,8 @@ export interface FormField { readonly $$type: '@backstage/scaffolder/FormField'; } + +/** @alpha */ +export interface ScaffolderFormFieldsApi { + loadFormFields(): Promise; +} diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index e553e706da..9baa74407f 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -17,6 +17,7 @@ import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react'; import { FilterPredicate } from '@backstage/filter-predicates'; import { FormField } from '@backstage/plugin-scaffolder-react/alpha'; +import { formFieldsApiRef } from '@backstage/plugin-scaffolder-react/alpha'; import type { FormProps as FormProps_2 } from '@rjsf/core'; import { FormProps as FormProps_3 } from '@backstage/plugin-scaffolder-react'; import { IconComponent } from '@backstage/frontend-plugin-api'; @@ -31,6 +32,7 @@ import { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; import { RouteRef } from '@backstage/core-plugin-api'; import { RouteRef as RouteRef_2 } from '@backstage/frontend-plugin-api'; import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; +import { ScaffolderFormFieldsApi } from '@backstage/plugin-scaffolder-react/alpha'; import { SubRouteRef } from '@backstage/core-plugin-api'; import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react'; @@ -480,8 +482,7 @@ export const formDecoratorsApi: OverridableExtensionDefinition<{ // @alpha (undocumented) export const formDecoratorsApiRef: ApiRef; -// @alpha (undocumented) -export const formFieldsApiRef: ApiRef; +export { formFieldsApiRef }; // @alpha @deprecated export type FormProps = Pick< @@ -502,11 +503,7 @@ export interface ScaffolderFormDecoratorsApi { getFormDecorators(): Promise; } -// @alpha (undocumented) -export interface ScaffolderFormFieldsApi { - // (undocumented) - loadFormFields(): Promise; -} +export { ScaffolderFormFieldsApi }; // @public (undocumented) export type ScaffolderTemplateEditorClassKey = diff --git a/plugins/scaffolder/src/alpha/extensions.tsx b/plugins/scaffolder/src/alpha/extensions.tsx index 8c17f8cc1c..91306bf5fd 100644 --- a/plugins/scaffolder/src/alpha/extensions.tsx +++ b/plugins/scaffolder/src/alpha/extensions.tsx @@ -25,11 +25,13 @@ import { } from '@backstage/frontend-plugin-api'; import { rootRouteRef } from '../routes'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; -import { FormFieldBlueprint } from '@backstage/plugin-scaffolder-react/alpha'; +import { + FormFieldBlueprint, + formFieldsApiRef, +} from '@backstage/plugin-scaffolder-react/alpha'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; import { ScaffolderClient } from '../api'; -import { formFieldsApiRef } from './formFieldsApi'; export const scaffolderPage = PageBlueprint.makeWithOverrides({ inputs: { diff --git a/plugins/scaffolder/src/alpha/formFieldsApi.ts b/plugins/scaffolder/src/alpha/formFieldsApi.ts index bcc2190f7e..b065af9a82 100644 --- a/plugins/scaffolder/src/alpha/formFieldsApi.ts +++ b/plugins/scaffolder/src/alpha/formFieldsApi.ts @@ -16,25 +16,14 @@ import { ApiBlueprint, - createApiRef, createExtensionInput, } from '@backstage/frontend-plugin-api'; import { FormFieldBlueprint, - type FormField, + formFieldsApiRef, } from '@backstage/plugin-scaffolder-react/alpha'; import { OpaqueFormField } from '@internal/scaffolder'; -/** @alpha */ -export interface ScaffolderFormFieldsApi { - loadFormFields(): Promise; -} - -/** @alpha */ -const formFieldsApiRef = createApiRef({ - id: 'plugin.scaffolder.form-fields-loader', -}); - export const formFieldsApi = ApiBlueprint.makeWithOverrides({ name: 'form-fields', inputs: { @@ -69,4 +58,7 @@ export const formFieldsApi = ApiBlueprint.makeWithOverrides({ }, }); -export { formFieldsApiRef }; +export { + formFieldsApiRef, + type ScaffolderFormFieldsApi, +} from '@backstage/plugin-scaffolder-react/alpha'; From 33cc7b218ea168cb4ba02c608f71125ba3588796 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 18:15:08 +0100 Subject: [PATCH 110/118] Address review feedback on changesets Signed-off-by: Patrik Oldsberg --- .changeset/fix-form-fields-api-export-react.md | 5 +++-- .changeset/fix-form-fields-api-export-scaffolder.md | 5 ----- 2 files changed, 3 insertions(+), 7 deletions(-) delete mode 100644 .changeset/fix-form-fields-api-export-scaffolder.md diff --git a/.changeset/fix-form-fields-api-export-react.md b/.changeset/fix-form-fields-api-export-react.md index ff6d260e0f..82e00c8351 100644 --- a/.changeset/fix-form-fields-api-export-react.md +++ b/.changeset/fix-form-fields-api-export-react.md @@ -1,5 +1,6 @@ --- -'@backstage/plugin-scaffolder-react': minor +'@backstage/plugin-scaffolder-react': patch +'@backstage/plugin-scaffolder': patch --- -Added `formFieldsApiRef` and `ScaffolderFormFieldsApi` as alpha exports. +Added back `formFieldsApiRef` and `ScaffolderFormFieldsApi` as alpha exports. diff --git a/.changeset/fix-form-fields-api-export-scaffolder.md b/.changeset/fix-form-fields-api-export-scaffolder.md deleted file mode 100644 index 8ad31c5593..0000000000 --- a/.changeset/fix-form-fields-api-export-scaffolder.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder': patch ---- - -The `formFieldsApiRef` and `ScaffolderFormFieldsApi` alpha exports are now re-exported from `@backstage/plugin-scaffolder-react`. From 743e4e3a1b42dbb407dd672141d1e472d76dd675 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 18:31:18 +0100 Subject: [PATCH 111/118] Remove scaffolder changeset, note API signature change Signed-off-by: Patrik Oldsberg --- .changeset/fix-form-fields-api-export-react.md | 3 +-- .changeset/scaffolder-export-form-fields-api.md | 5 ----- 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 .changeset/scaffolder-export-form-fields-api.md diff --git a/.changeset/fix-form-fields-api-export-react.md b/.changeset/fix-form-fields-api-export-react.md index 82e00c8351..9ea6ec4930 100644 --- a/.changeset/fix-form-fields-api-export-react.md +++ b/.changeset/fix-form-fields-api-export-react.md @@ -1,6 +1,5 @@ --- '@backstage/plugin-scaffolder-react': patch -'@backstage/plugin-scaffolder': patch --- -Added back `formFieldsApiRef` and `ScaffolderFormFieldsApi` as alpha exports. +Added back `formFieldsApiRef` and `ScaffolderFormFieldsApi` as alpha exports. The API signature of `@backstage/plugin-scaffolder` changed as these are now re-exported from `@backstage/plugin-scaffolder-react`. diff --git a/.changeset/scaffolder-export-form-fields-api.md b/.changeset/scaffolder-export-form-fields-api.md deleted file mode 100644 index 02ead2d2b2..0000000000 --- a/.changeset/scaffolder-export-form-fields-api.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder': patch ---- - -Added back the `formFieldsApiRef` and `ScaffolderFormFieldsApi` alpha exports that were unintentionally removed. From 4a9b1891255404a493322ff3619057760d4bcbbc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 18:31:50 +0100 Subject: [PATCH 112/118] Add .patches entry for patch release Signed-off-by: Patrik Oldsberg --- .patches/pr-33034.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 .patches/pr-33034.txt diff --git a/.patches/pr-33034.txt b/.patches/pr-33034.txt new file mode 100644 index 0000000000..ab8beea429 --- /dev/null +++ b/.patches/pr-33034.txt @@ -0,0 +1 @@ +Moves `formFieldsApiRef` and `ScaffolderFormFieldsApi` alpha exports to `@backstage/plugin-scaffolder-react`. The API signature of `@backstage/plugin-scaffolder` changed as these are now re-exported from `@backstage/plugin-scaffolder-react`. \ No newline at end of file From 6b70a2abcf8ced8b6d5dbe0bfcf7d2d68cdf6934 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 19:09:44 +0100 Subject: [PATCH 113/118] Apply suggestions from code review Signed-off-by: Patrik Oldsberg --- .changeset/fix-form-fields-api-export-react.md | 2 +- .patches/pr-33034.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/fix-form-fields-api-export-react.md b/.changeset/fix-form-fields-api-export-react.md index 9ea6ec4930..787131280a 100644 --- a/.changeset/fix-form-fields-api-export-react.md +++ b/.changeset/fix-form-fields-api-export-react.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-react': patch --- -Added back `formFieldsApiRef` and `ScaffolderFormFieldsApi` as alpha exports. The API signature of `@backstage/plugin-scaffolder` changed as these are now re-exported from `@backstage/plugin-scaffolder-react`. +Added back `formFieldsApiRef` and `ScaffolderFormFieldsApi` as alpha exports. diff --git a/.patches/pr-33034.txt b/.patches/pr-33034.txt index ab8beea429..4648da00b2 100644 --- a/.patches/pr-33034.txt +++ b/.patches/pr-33034.txt @@ -1 +1 @@ -Moves `formFieldsApiRef` and `ScaffolderFormFieldsApi` alpha exports to `@backstage/plugin-scaffolder-react`. The API signature of `@backstage/plugin-scaffolder` changed as these are now re-exported from `@backstage/plugin-scaffolder-react`. \ No newline at end of file +Added back `formFieldsApiRef` and `ScaffolderFormFieldsApi` alpha exports to `@backstage/plugin-scaffolder-react`. This change was incorrectly applied to `@backstage/plugin-scaffolder` in the 1.48.2 release, which is now gone. Note that the API signature of this API has still changed since the 1.47 release. \ No newline at end of file From 972f686958350199d706e03892c4fe611aca9859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 21:03:25 +0100 Subject: [PATCH 114/118] feat(catalog): Add predicate-based filtering to the by-refs endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for predicate-based filtering (`$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, `$hasPrefix`) to the catalog `/entities/by-refs` endpoint via a `query` field in the request body. The existing `filter` query parameter behavior is preserved for backward compatibility. The `query` predicate is only used when explicitly provided, and when both `filter` and `query` are given, they are merged with `$all`. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- .changeset/by-refs-predicate-backend.md | 5 + .changeset/by-refs-predicate-client.md | 5 + packages/catalog-client/report.api.md | 1 + .../catalog-client/src/CatalogClient.test.ts | 97 +++++++++++++++++++ packages/catalog-client/src/CatalogClient.ts | 29 +++++- .../models/GetEntitiesByRefsRequest.model.ts | 4 + packages/catalog-client/src/types/api.ts | 10 ++ plugins/catalog-backend/src/catalog/types.ts | 4 + .../catalog-backend/src/schema/openapi.yaml | 2 + .../models/GetEntitiesByRefsRequest.model.ts | 4 + .../src/schema/openapi/generated/router.ts | 3 + .../src/service/DefaultEntitiesCatalog.ts | 3 +- .../src/service/createRouter.test.ts | 48 +++++++++ .../src/service/createRouter.ts | 1 + .../service/request/entitiesBatchRequest.ts | 35 ++++++- 15 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 .changeset/by-refs-predicate-backend.md create mode 100644 .changeset/by-refs-predicate-client.md diff --git a/.changeset/by-refs-predicate-backend.md b/.changeset/by-refs-predicate-backend.md new file mode 100644 index 0000000000..a2a0346122 --- /dev/null +++ b/.changeset/by-refs-predicate-backend.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Added support for predicate-based filtering on the `/entities/by-refs` endpoint via the `query` field in the request body. Supports `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators. diff --git a/.changeset/by-refs-predicate-client.md b/.changeset/by-refs-predicate-client.md new file mode 100644 index 0000000000..5ed136fbe1 --- /dev/null +++ b/.changeset/by-refs-predicate-client.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': minor +--- + +Added support for the `query` field in `getEntitiesByRefs` requests, enabling predicate-based filtering with `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators. diff --git a/packages/catalog-client/report.api.md b/packages/catalog-client/report.api.md index 8bfebe6ab1..c155084ccd 100644 --- a/packages/catalog-client/report.api.md +++ b/packages/catalog-client/report.api.md @@ -236,6 +236,7 @@ export interface GetEntitiesByRefsRequest { entityRefs: string[]; fields?: EntityFieldsQuery | undefined; filter?: EntityFilterQuery; + query?: FilterPredicate; } // @public diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 819fa70381..70265bd6cb 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -302,6 +302,103 @@ describe('CatalogClient', () => { expect(response).toEqual({ items: [entity, undefined] }); }); + + it('sends only query predicate in the body when query is provided without filter', async () => { + expect.assertions(3); + const entity = { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'Test2', + namespace: 'test1', + }, + }; + server.use( + rest.post(`${mockBaseUrl}/entities/by-refs`, async (req, res, ctx) => { + expect(req.url.search).toBe(''); + await expect(req.json()).resolves.toEqual({ + entityRefs: ['k:n/a'], + query: { kind: 'Component' }, + }); + return res(ctx.json({ items: [entity] })); + }), + ); + + const response = await client.getEntitiesByRefs( + { + entityRefs: ['k:n/a'], + query: { kind: 'Component' }, + }, + { token }, + ); + + expect(response).toEqual({ items: [entity] }); + }); + + it('merges filter and query into $all predicate when both are provided', async () => { + expect.assertions(4); + const entity = { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'Test2', + namespace: 'test1', + }, + }; + server.use( + rest.post(`${mockBaseUrl}/entities/by-refs`, async (req, res, ctx) => { + expect(req.url.search).toBe(''); + const body = await req.json(); + expect(body.entityRefs).toEqual(['k:n/a']); + expect(body.query).toEqual({ + $all: [{ kind: 'Component' }, { kind: 'API' }], + }); + return res(ctx.json({ items: [entity] })); + }), + ); + + const response = await client.getEntitiesByRefs( + { + entityRefs: ['k:n/a'], + query: { kind: 'Component' }, + filter: { kind: ['API'] }, + }, + { token }, + ); + + expect(response).toEqual({ items: [entity] }); + }); + + it('sends filter as query parameter when only filter is provided (backward compat)', async () => { + expect.assertions(4); + const entity = { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'Test2', + namespace: 'test1', + }, + }; + server.use( + rest.post(`${mockBaseUrl}/entities/by-refs`, async (req, res, ctx) => { + expect(req.url.search).toBe('?filter=kind%3DAPI'); + const body = await req.json(); + expect(body).toEqual({ entityRefs: ['k:n/a'] }); + expect(body.query).toBeUndefined(); + return res(ctx.json({ items: [entity] })); + }), + ); + + const response = await client.getEntitiesByRefs( + { + entityRefs: ['k:n/a'], + filter: { kind: ['API'] }, + }, + { token }, + ); + + expect(response).toEqual({ items: [entity] }); + }); }); describe('queryEntities', () => { diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 0d173022a3..a671a155d3 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -237,11 +237,36 @@ export class CatalogClient implements CatalogApi { request: GetEntitiesByRefsRequest, options?: CatalogRequestOptions, ): Promise { + const { filter, query } = request; + + // Only convert and merge if both filter and query are provided, or if + // query alone is provided. When only filter is given, preserve the old + // query-parameter behavior for backward compatibility. + let filterPredicate: FilterPredicate | undefined; + if (query !== undefined) { + if (typeof query !== 'object' || query === null || Array.isArray(query)) { + throw new InputError('Query must be an object'); + } + filterPredicate = query; + if (filter !== undefined) { + const converted = convertFilterToPredicate(filter); + filterPredicate = { $all: [filterPredicate, converted] }; + } + } + const getOneChunk = async (refs: string[]) => { const response = await this.apiClient.getEntitiesByRefs( { - body: { entityRefs: refs, fields: request.fields }, - query: { filter: this.getFilterValue(request.filter) }, + body: { + entityRefs: refs, + fields: request.fields, + ...(filterPredicate && { + query: filterPredicate as unknown as { [key: string]: any }, + }), + }, + query: filterPredicate + ? {} + : { filter: this.getFilterValue(request.filter) }, }, options, ); diff --git a/packages/catalog-client/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts index 0e7255f9c6..607c992ace 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts @@ -24,4 +24,8 @@ export interface GetEntitiesByRefsRequest { entityRefs: Array; fields?: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; } diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 13a7822df9..fac4ed5928 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -212,6 +212,16 @@ export interface GetEntitiesByRefsRequest { * If given, return only entities that match the given filter. */ filter?: EntityFilterQuery; + /** + * If given, return only entities that match the given predicate query. + * + * @remarks + * + * Supports operators like `$all`, `$any`, `$not`, `$exists`, `$in`, + * `$contains`, and `$hasPrefix`. When both `filter` and `query` are + * provided, they are combined with `$all`. + */ + query?: FilterPredicate; } /** diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index d5c4b2b675..3d6aff55ba 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -86,6 +86,10 @@ export interface EntitiesBatchRequest { * they did not exist. */ filter?: EntityFilter; + /** + * Predicate-based query for filtering entities. + */ + query?: FilterPredicate; /** * Strips out only the parts of the entity bodies to include in the response. */ diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index 58d7fed4ac..382dfedade 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -1036,6 +1036,8 @@ paths: type: array items: type: string + query: + $ref: '#/components/schemas/JsonObject' examples: Fetch Backstage entities: value: diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts index 0e7255f9c6..607c992ace 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts @@ -24,4 +24,8 @@ export interface GetEntitiesByRefsRequest { entityRefs: Array; fields?: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; } diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index b423905955..74668310c5 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -1124,6 +1124,9 @@ export const spec = { type: 'string', }, }, + query: { + $ref: '#/components/schemas/JsonObject', + }, }, }, examples: { diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index a9d706582a..38cd145421 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -224,9 +224,10 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { }) .whereIn('final_entities.entity_ref', chunk); - if (request?.filter) { + if (request?.filter || request?.query) { query = applyEntityFilterToQuery({ filter: request.filter, + query: request.query, targetQuery: query, onEntityIdField: 'final_entities.entity_id', knex: this.database, diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index ccdd034cae..62856ba7e4 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -725,6 +725,54 @@ describe('createRouter readonly disabled', () => { }); }); + describe('POST /entities/by-refs with query predicate', () => { + it('can fetch entities by refs with a predicate query', async () => { + const entity: Entity = { + apiVersion: 'a', + kind: 'component', + metadata: { + name: 'a', + }, + }; + const entityRef = stringifyEntityRef(entity); + entitiesCatalog.entitiesBatch.mockResolvedValue({ + items: { type: 'object', entities: [entity] }, + }); + const response = await request(app) + .post('/entities/by-refs') + .set('Content-Type', 'application/json') + .send( + JSON.stringify({ + entityRefs: [entityRef], + query: { kind: 'Component' }, + }), + ); + expect(entitiesCatalog.entitiesBatch).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entitiesBatch).toHaveBeenCalledWith({ + entityRefs: [entityRef], + fields: undefined, + credentials: mockCredentials.user(), + filter: undefined, + query: { kind: 'Component' }, + }); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ items: [entity] }); + }); + + it('rejects invalid query predicate', async () => { + const response = await request(app) + .post('/entities/by-refs') + .set('Content-Type', 'application/json') + .send( + JSON.stringify({ + entityRefs: ['component:default/a'], + query: { $invalid: 'bad' }, + }), + ); + expect(response.status).toEqual(400); + }); + }); + describe('GET /locations', () => { it('happy path: lists locations', async () => { const locations: Location[] = [ diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 90c76cc4d4..1a7f1bf6fc 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -525,6 +525,7 @@ export async function createRouter( const { items } = await entitiesCatalog.entitiesBatch({ entityRefs: request.entityRefs, filter: parseEntityFilterParams(req.query), + query: request.query, fields: parseEntityTransformParams(req.query, request.fields), credentials: await httpAuth.credentials(req), }); diff --git a/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts b/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts index d5469315be..e3d8252d4c 100644 --- a/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts +++ b/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts @@ -15,20 +15,51 @@ */ import { InputError } from '@backstage/errors'; +import { + createZodV3FilterPredicateSchema, + FilterPredicate, +} from '@backstage/filter-predicates'; import { Request } from 'express'; import { z } from 'zod'; +import { z as zodV3 } from 'zod/v3'; +import { fromZodError } from 'zod-validation-error/v3'; const schema = z.object({ entityRefs: z.array(z.string()), fields: z.array(z.string()).optional(), + query: z.record(z.unknown()).optional(), }); -export function entitiesBatchRequest(req: Request): z.infer { +const filterPredicateSchema = createZodV3FilterPredicateSchema(zodV3); + +export interface ParsedEntitiesBatchRequest { + entityRefs: string[]; + fields?: string[]; + query?: FilterPredicate; +} + +export function entitiesBatchRequest(req: Request): ParsedEntitiesBatchRequest { + let parsed: z.infer; try { - return schema.parse(req.body); + parsed = schema.parse(req.body); } catch (error) { throw new InputError( `Malformed request body (did you remember to specify an application/json content type?), ${error.message}`, ); } + + let query: FilterPredicate | undefined; + if (parsed.query !== undefined) { + const result = filterPredicateSchema.safeParse(parsed.query); + if (!result.success) { + throw new InputError(`Invalid query: ${fromZodError(result.error)}`); + } + query = result.data; + } + + return { + entityRefs: parsed.entityRefs, + fields: parsed.fields, + query, + }; } From 5aea009eddfd8e886d6b411bd2892f9cf27ccf0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 21:11:06 +0100 Subject: [PATCH 115/118] refactor: simplify entitiesBatchRequest to use filterPredicateSchema directly 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 --- .../service/request/entitiesBatchRequest.ts | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts b/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts index e3d8252d4c..02ad1d5566 100644 --- a/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts +++ b/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts @@ -20,18 +20,17 @@ import { FilterPredicate, } from '@backstage/filter-predicates'; import { Request } from 'express'; -import { z } from 'zod'; -import { z as zodV3 } from 'zod/v3'; +import { z } from 'zod/v3'; import { fromZodError } from 'zod-validation-error/v3'; +const filterPredicateSchema = createZodV3FilterPredicateSchema(z); + const schema = z.object({ entityRefs: z.array(z.string()), fields: z.array(z.string()).optional(), - query: z.record(z.unknown()).optional(), + query: filterPredicateSchema.optional(), }); -const filterPredicateSchema = createZodV3FilterPredicateSchema(zodV3); - export interface ParsedEntitiesBatchRequest { entityRefs: string[]; fields?: string[]; @@ -39,27 +38,13 @@ export interface ParsedEntitiesBatchRequest { } export function entitiesBatchRequest(req: Request): ParsedEntitiesBatchRequest { - let parsed: z.infer; - try { - parsed = schema.parse(req.body); - } catch (error) { + const result = schema.safeParse(req.body); + if (!result.success) { throw new InputError( - `Malformed request body (did you remember to specify an application/json content type?), ${error.message}`, + `Malformed request body (did you remember to specify an application/json content type?), ${fromZodError( + result.error, + )}`, ); } - - let query: FilterPredicate | undefined; - if (parsed.query !== undefined) { - const result = filterPredicateSchema.safeParse(parsed.query); - if (!result.success) { - throw new InputError(`Invalid query: ${fromZodError(result.error)}`); - } - query = result.data; - } - - return { - entityRefs: parsed.entityRefs, - fields: parsed.fields, - query, - }; + return result.data; } From 2ffb23df02e63f56096c0c22b8cac6ddea779e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 21:26:33 +0100 Subject: [PATCH 116/118] Update packages/catalog-client/src/CatalogClient.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Fredrik Adelöw --- packages/catalog-client/src/CatalogClient.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index a671a155d3..404c980128 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -249,8 +249,11 @@ export class CatalogClient implements CatalogApi { } filterPredicate = query; if (filter !== undefined) { - const converted = convertFilterToPredicate(filter); - filterPredicate = { $all: [filterPredicate, converted] }; + const filterValue = this.getFilterValue(filter); + if (filterValue && Object.keys(filterValue).length > 0) { + const converted = convertFilterToPredicate(filter); + filterPredicate = { $all: [filterPredicate, converted] }; + } } } From ec411a01ae8933d96f48f4ab957b2410fcbfd915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 21:50:33 +0100 Subject: [PATCH 117/118] feat: add query predicate support to InMemoryCatalogClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for the `query` field in `getEntitiesByRefs` and `getEntityFacets` on InMemoryCatalogClient, matching the backend behavior. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- .../testUtils/InMemoryCatalogClient.test.ts | 42 +++++++++++++++++++ .../src/testUtils/InMemoryCatalogClient.ts | 14 ++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts index aa3b624050..98b5d5c034 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts @@ -370,6 +370,25 @@ describe('InMemoryCatalogClient', () => { { kind: 'CustomKind', metadata: { name: 'e1' } }, ]); }); + + it('supports query predicate filter', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntitiesByRefs({ + entityRefs: ['secondcustomkind:default/e2', 'customkind:default/e1'], + query: { kind: 'CustomKind' }, + }); + expect(result.items).toEqual([undefined, entity1]); + }); + + it('supports both filter and query predicate together', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntitiesByRefs({ + entityRefs: ['customkind:default/e1', 'customkind:other/e3'], + filter: { kind: 'CustomKind' }, + query: { 'metadata.namespace': 'other' }, + }); + expect(result.items).toEqual([undefined, entity3]); + }); }); describe('queryEntities', () => { @@ -975,6 +994,29 @@ describe('InMemoryCatalogClient', () => { }); expect(result.facets['spec.nonexistent']).toEqual([]); }); + + it('supports query predicate filter', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntityFacets({ + facets: ['spec.type'], + query: { kind: 'CustomKind' }, + }); + expect(result.facets['spec.type']).toEqual([ + { value: 'service', count: 2 }, + ]); + }); + + it('supports both filter and query predicate together', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntityFacets({ + facets: ['spec.type'], + filter: { kind: 'CustomKind' }, + query: { 'metadata.namespace': 'default' }, + }); + expect(result.facets['spec.type']).toEqual([ + { value: 'service', count: 1 }, + ]); + }); }); describe('not implemented methods', () => { diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts index 0a4dc57386..dd7f269188 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts @@ -361,10 +361,15 @@ export class InMemoryCatalogClient implements CatalogApi { request: GetEntitiesByRefsRequest, ): Promise { const filter = createFilter(request.filter); + const queryFilter = request.query + ? filterPredicateToFilterFunction(request.query) + : undefined; const refMap = this.#createEntityRefMap(); const items = request.entityRefs .map(ref => refMap.get(ref)) - .map(e => (e && filter(e) ? e : undefined)); + .map(e => + e && filter(e) && (!queryFilter || queryFilter(e)) ? e : undefined, + ); return { items: request.fields ? items.map(e => (e ? applyFieldsFilter(e, request.fields) : undefined)) @@ -506,7 +511,12 @@ export class InMemoryCatalogClient implements CatalogApi { request: GetEntityFacetsRequest, ): Promise { const filter = createFilter(request.filter); - const filteredEntities = this.#entities.filter(filter); + let filteredEntities = this.#entities.filter(filter); + if (request.query) { + filteredEntities = filteredEntities.filter( + filterPredicateToFilterFunction(request.query), + ); + } const facets = Object.fromEntries( request.facets.map(facet => { const facetValues = new Map(); From 3880820c62c75d62d9bfa5f5b2d380e05396073d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 26 Feb 2026 21:59:36 +0100 Subject: [PATCH 118/118] fix: clean up filter merging logic and add combined filter+query test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace incorrect Object.keys-on-array guard with direct filter conversion, matching the pattern used in getEntityFacetsByPredicate - Add backend router test covering combined filter query param + body query predicate being forwarded independently to entitiesBatch Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Fredrik Adelöw --- packages/catalog-client/src/CatalogClient.ts | 7 ++--- .../src/service/createRouter.test.ts | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 404c980128..a671a155d3 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -249,11 +249,8 @@ export class CatalogClient implements CatalogApi { } filterPredicate = query; if (filter !== undefined) { - const filterValue = this.getFilterValue(filter); - if (filterValue && Object.keys(filterValue).length > 0) { - const converted = convertFilterToPredicate(filter); - filterPredicate = { $all: [filterPredicate, converted] }; - } + const converted = convertFilterToPredicate(filter); + filterPredicate = { $all: [filterPredicate, converted] }; } } diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 62856ba7e4..1984f2f04f 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -759,6 +759,37 @@ describe('createRouter readonly disabled', () => { expect(response.body).toEqual({ items: [entity] }); }); + it('forwards both filter query param and body query predicate independently', async () => { + const entity: Entity = { + apiVersion: 'a', + kind: 'component', + metadata: { + name: 'a', + }, + }; + const entityRef = stringifyEntityRef(entity); + entitiesCatalog.entitiesBatch.mockResolvedValue({ + items: { type: 'object', entities: [entity] }, + }); + const response = await request(app) + .post('/entities/by-refs?filter=kind=Component') + .set('Content-Type', 'application/json') + .send( + JSON.stringify({ + entityRefs: [entityRef], + query: { 'metadata.namespace': 'default' }, + }), + ); + expect(entitiesCatalog.entitiesBatch).toHaveBeenCalledWith({ + entityRefs: [entityRef], + fields: undefined, + credentials: mockCredentials.user(), + filter: { key: 'kind', values: ['Component'] }, + query: { 'metadata.namespace': 'default' }, + }); + expect(response.status).toEqual(200); + }); + it('rejects invalid query predicate', async () => { const response = await request(app) .post('/entities/by-refs')