From a6b28199e541da0819e2ce63ad53f04ac102f0e5 Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Thu, 26 Feb 2026 14:45:10 +0100 Subject: [PATCH] `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); };