feat(catalog): Implement query-catalog-entities on top of the filter predicates (#33022)
* chore: create query catalog Signed-off-by: benjdlambert <ben@blam.sh> Signed-off-by: benjdlambert <ben@blam.sh> * feat(catalog-backend): add query-catalog-entities action Signed-off-by: benjdlambert <ben@blam.sh> * fix: address PR review feedback Signed-off-by: benjdlambert <ben@blam.sh> --------- Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -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.
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.<type>" 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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user