From b883042faccf42b4a7ac3b6298783667b6a9c5cc Mon Sep 17 00:00:00 2001 From: Nilay1999 Date: Mon, 12 Jan 2026 11:32:28 +0530 Subject: [PATCH] =?UTF-8?q?feat(catalog-backend):=20=E2=9C=A8=20add=20post?= =?UTF-8?q?=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',