catalog-react: add initial MongoDB-based entity predicates
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -102,7 +102,8 @@
|
||||
"react": "^18.0.2",
|
||||
"react-dom": "^18.0.2",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-test-renderer": "^16.13.1"
|
||||
"react-test-renderer": "^16.13.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^17.0.0 || ^18.0.0",
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
entityCardTypes,
|
||||
EntityCardType,
|
||||
} from './extensionData';
|
||||
import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema';
|
||||
import { EntityPredicate } from '../predicates';
|
||||
import { resolveEntityFilterData } from './resolveEntityFilterData';
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
@@ -47,7 +50,8 @@ export const EntityCardBlueprint = createExtensionBlueprint({
|
||||
},
|
||||
config: {
|
||||
schema: {
|
||||
filter: z => z.string().optional(),
|
||||
filter: z =>
|
||||
z.union([z.string(), createEntityPredicateSchema(z)]).optional(),
|
||||
type: z => z.enum(entityCardTypes).optional(),
|
||||
},
|
||||
},
|
||||
@@ -58,22 +62,14 @@ export const EntityCardBlueprint = createExtensionBlueprint({
|
||||
type,
|
||||
}: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?:
|
||||
| typeof entityFilterFunctionDataRef.T
|
||||
| typeof entityFilterExpressionDataRef.T;
|
||||
filter?: EntityPredicate | typeof entityFilterFunctionDataRef.T;
|
||||
type?: EntityCardType;
|
||||
},
|
||||
{ node, config },
|
||||
) {
|
||||
yield coreExtensionData.reactElement(ExtensionBoundary.lazy(node, loader));
|
||||
|
||||
if (config.filter) {
|
||||
yield entityFilterExpressionDataRef(config.filter);
|
||||
} else if (typeof filter === 'string') {
|
||||
yield entityFilterExpressionDataRef(filter);
|
||||
} else if (typeof filter === 'function') {
|
||||
yield entityFilterFunctionDataRef(filter);
|
||||
}
|
||||
yield* resolveEntityFilterData(filter, config, node);
|
||||
|
||||
const finalType = config.type ?? type;
|
||||
if (finalType) {
|
||||
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
entityContentGroupDataRef,
|
||||
defaultEntityContentGroups,
|
||||
} from './extensionData';
|
||||
import { EntityPredicate } from '../predicates';
|
||||
import { resolveEntityFilterData } from './resolveEntityFilterData';
|
||||
import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema';
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
@@ -54,7 +57,8 @@ export const EntityContentBlueprint = createExtensionBlueprint({
|
||||
schema: {
|
||||
path: z => z.string().optional(),
|
||||
title: z => z.string().optional(),
|
||||
filter: z => z.string().optional(),
|
||||
filter: z =>
|
||||
z.union([z.string(), createEntityPredicateSchema(z)]).optional(),
|
||||
group: z => z.literal(false).or(z.string()).optional(),
|
||||
},
|
||||
},
|
||||
@@ -72,9 +76,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({
|
||||
defaultTitle: string;
|
||||
defaultGroup?: keyof typeof defaultEntityContentGroups | (string & {});
|
||||
routeRef?: RouteRef;
|
||||
filter?:
|
||||
| typeof entityFilterFunctionDataRef.T
|
||||
| typeof entityFilterExpressionDataRef.T;
|
||||
filter?: string | EntityPredicate | typeof entityFilterFunctionDataRef.T;
|
||||
},
|
||||
{ node, config },
|
||||
) {
|
||||
@@ -92,13 +94,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({
|
||||
yield coreExtensionData.routeRef(routeRef);
|
||||
}
|
||||
|
||||
if (config.filter) {
|
||||
yield entityFilterExpressionDataRef(config.filter);
|
||||
} else if (typeof filter === 'string') {
|
||||
yield entityFilterExpressionDataRef(filter);
|
||||
} else if (typeof filter === 'function') {
|
||||
yield entityFilterFunctionDataRef(filter);
|
||||
}
|
||||
yield* resolveEntityFilterData(filter, config, node);
|
||||
|
||||
if (group) {
|
||||
yield entityContentGroupDataRef(group);
|
||||
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
EntityCardType,
|
||||
} from './extensionData';
|
||||
import React from 'react';
|
||||
import { EntityPredicate } from '../predicates';
|
||||
import { resolveEntityFilterData } from './resolveEntityFilterData';
|
||||
import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema';
|
||||
|
||||
/** @alpha */
|
||||
export interface EntityContentLayoutProps {
|
||||
@@ -57,7 +60,8 @@ export const EntityContentLayoutBlueprint = createExtensionBlueprint({
|
||||
config: {
|
||||
schema: {
|
||||
type: z => z.string().optional(),
|
||||
filter: z => z.string().optional(),
|
||||
filter: z =>
|
||||
z.union([z.string(), createEntityPredicateSchema(z)]).optional(),
|
||||
},
|
||||
},
|
||||
*factory(
|
||||
@@ -65,22 +69,14 @@ export const EntityContentLayoutBlueprint = createExtensionBlueprint({
|
||||
loader,
|
||||
filter,
|
||||
}: {
|
||||
filter?:
|
||||
| typeof entityFilterFunctionDataRef.T
|
||||
| typeof entityFilterExpressionDataRef.T;
|
||||
filter?: string | EntityPredicate | typeof entityFilterFunctionDataRef.T;
|
||||
loader: () => Promise<
|
||||
(props: EntityContentLayoutProps) => React.JSX.Element
|
||||
>;
|
||||
},
|
||||
{ node, config },
|
||||
) {
|
||||
if (config.filter) {
|
||||
yield entityFilterExpressionDataRef(config.filter);
|
||||
} else if (typeof filter === 'string') {
|
||||
yield entityFilterExpressionDataRef(filter);
|
||||
} else if (typeof filter === 'function') {
|
||||
yield entityFilterFunctionDataRef(filter);
|
||||
}
|
||||
yield* resolveEntityFilterData(filter, config, node);
|
||||
|
||||
yield entityCardLayoutComponentDataRef(
|
||||
ExtensionBoundary.lazyComponent(node, loader),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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 {
|
||||
entityFilterExpressionDataRef,
|
||||
entityFilterFunctionDataRef,
|
||||
} from './extensionData';
|
||||
import {
|
||||
EntityPredicate,
|
||||
entityPredicateToFilterFunction,
|
||||
} from '../predicates';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { AppNode } from '@backstage/frontend-plugin-api';
|
||||
|
||||
export function* resolveEntityFilterData(
|
||||
filter: ((entity: Entity) => boolean) | EntityPredicate | string | undefined,
|
||||
config: { filter?: EntityPredicate | string },
|
||||
node: AppNode,
|
||||
) {
|
||||
if (typeof config.filter === 'string') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`DEPRECATION WARNING: Using a string-based filter in the configuration for '${node.spec.id}' is deprecated. Use an entity predicate object instead.`,
|
||||
);
|
||||
yield entityFilterExpressionDataRef(config.filter);
|
||||
} else if (config.filter) {
|
||||
yield entityFilterFunctionDataRef(
|
||||
entityPredicateToFilterFunction(config.filter),
|
||||
);
|
||||
} else if (typeof filter === 'function') {
|
||||
yield entityFilterFunctionDataRef(filter);
|
||||
} else if (typeof filter === 'string') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`DEPRECATION WARNING: Using a string as the default filter for '${node.spec.id}' is deprecated. Use an entity predicate object instead.`,
|
||||
);
|
||||
yield entityFilterExpressionDataRef(filter);
|
||||
} else if (filter) {
|
||||
yield entityFilterFunctionDataRef(entityPredicateToFilterFunction(filter));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
export * from './blueprints';
|
||||
export * from './converters';
|
||||
export * from './predicates';
|
||||
export { catalogReactTranslationRef } from '../translation';
|
||||
export { isOwnerOf } from '../utils/isOwnerOf';
|
||||
export { useEntityPermission } from '../hooks/useEntityPermission';
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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 { z } from 'zod';
|
||||
import { createEntityPredicateSchema } from './createEntityPredicateSchema';
|
||||
|
||||
describe('createEntityPredicateSchema', () => {
|
||||
const schema = createEntityPredicateSchema(z);
|
||||
|
||||
it.each([
|
||||
{ kind: 'component', 'spec.type': 'service' },
|
||||
{ 'metadata.tags': { $all: ['java'] } },
|
||||
{ 'metadata.tags': { $all: ['java', 'spring'] } },
|
||||
{ 'metadata.tags': ['java', 'spring'] },
|
||||
{ 'metadata.tags': { $all: ['go'] } },
|
||||
{ 'metadata.tags.0': 'java' },
|
||||
{ $not: { 'metadata.tags': { $all: ['java'] } } },
|
||||
{
|
||||
$or: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'group' }],
|
||||
},
|
||||
{
|
||||
$nor: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'group' }],
|
||||
},
|
||||
{
|
||||
relations: {
|
||||
$elemMatch: { type: 'ownedBy', targetRef: 'group:default/g' },
|
||||
},
|
||||
},
|
||||
{
|
||||
metadata: { $elemMatch: { name: 'a' } },
|
||||
},
|
||||
{ kind: 'component', 'spec.type': { $in: ['service', 'website'] } },
|
||||
{
|
||||
$or: [
|
||||
{
|
||||
$and: [
|
||||
{
|
||||
kind: 'component',
|
||||
'spec.type': { $in: ['service', 'website'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ $and: [{ kind: 'api', 'spec.type': 'grpc' }] },
|
||||
],
|
||||
},
|
||||
{ kind: 'component', 'spec.type': { $in: ['service'] } },
|
||||
{ kind: 'component', 'spec.type': { $nin: ['service'] } },
|
||||
{ 'spec.owner': { $exists: true } },
|
||||
{ 'spec.owner': { $exists: false } },
|
||||
{ 'spec.type': { $eq: 'service' } },
|
||||
{ 'spec.type': { $ne: 'service' } },
|
||||
{
|
||||
kind: 'component',
|
||||
'metadata.annotations.github.com/repo': { $exists: true },
|
||||
},
|
||||
{ $and: [{ x: { $exists: true } }] },
|
||||
{ $or: [{ x: { $exists: true } }] },
|
||||
{ $nor: [{ x: { $exists: true } }] },
|
||||
{ $not: { x: { $exists: true } } },
|
||||
{ $not: { $and: [{ x: { $exists: true } }] } },
|
||||
])('should accept valid predicate %j', predicate => {
|
||||
expect(schema.parse(predicate)).toEqual(predicate);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: { 1: 'foo' } },
|
||||
{ kind: { foo: 'bar' } },
|
||||
{ kind: { $unknown: 'foo' } },
|
||||
{ kind: { $in: 'foo' } },
|
||||
{ kind: { $in: [{ x: 'foo' }] } },
|
||||
{ kind: { $in: [{ x: 'foo' }] } },
|
||||
{ 'spec.type': null },
|
||||
'string',
|
||||
'',
|
||||
[],
|
||||
1,
|
||||
{ $and: [{ x: { $unknown: true } }] },
|
||||
{ $or: [{ x: { $unknown: true } }] },
|
||||
{ $nor: [{ x: { $unknown: true } }] },
|
||||
{ $not: { x: { $unknown: true } } },
|
||||
{ $not: { $and: [{ x: { $unknown: true } }] } },
|
||||
{ $unknown: 'foo' },
|
||||
])('should reject invalid predicate %j', predicate => {
|
||||
const result = schema.safeParse(predicate);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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 { EntityPredicate, EntityPredicateValue } from '.';
|
||||
import type { z as zImpl, ZodType } from 'zod';
|
||||
|
||||
/** @internal */
|
||||
export function createEntityPredicateSchema(z: typeof zImpl) {
|
||||
const primitiveSchema = z.union([z.string(), z.number(), z.boolean()]);
|
||||
|
||||
const filterValueSchema = z.union([
|
||||
primitiveSchema,
|
||||
z.array(primitiveSchema),
|
||||
z.object({ $exists: z.boolean() }),
|
||||
z.object({ $eq: z.union([primitiveSchema, z.array(primitiveSchema)]) }),
|
||||
z.object({ $ne: z.union([primitiveSchema, z.array(primitiveSchema)]) }),
|
||||
z.object({ $in: z.array(primitiveSchema) }),
|
||||
z.object({ $nin: z.array(primitiveSchema) }),
|
||||
z.object({ $all: z.array(primitiveSchema) }),
|
||||
z.object({ $elemMatch: z.lazy(() => z.record(filterValueSchema)) }),
|
||||
]) as ZodType<EntityPredicateValue>;
|
||||
|
||||
const filterSchema = z.lazy(() =>
|
||||
z.union([
|
||||
z.object({ $and: z.array(filterSchema) }),
|
||||
z.object({ $or: z.array(filterSchema) }),
|
||||
z.object({ $nor: z.array(filterSchema) }),
|
||||
z.object({ $not: filterSchema }),
|
||||
z.record(z.string().regex(/^(?!\$).*$/), filterValueSchema),
|
||||
]),
|
||||
) as ZodType<EntityPredicate>;
|
||||
|
||||
return filterSchema;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* 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 { evaluateEntityPredicate } from './evaluateEntityPredicate';
|
||||
import { EntityPredicate } from './types';
|
||||
|
||||
describe('evaluateEntityPredicate', () => {
|
||||
const entities = [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 's',
|
||||
namespace: 'default',
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location': 'url:service',
|
||||
'github.com/repo': 'service',
|
||||
},
|
||||
tags: ['java', 'spring'],
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
owner: 'g',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'ownedBy',
|
||||
targetRef: 'group:default/g',
|
||||
},
|
||||
{
|
||||
type: 'providesApi',
|
||||
targetRef: 'api:default/a',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'w',
|
||||
namespace: 'default',
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location': 'url:website',
|
||||
'github.com/repo': 'website',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
type: 'website',
|
||||
owner: 'g',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'ownedBy',
|
||||
targetRef: 'group:default/g',
|
||||
},
|
||||
{
|
||||
type: 'dependsOn',
|
||||
targetRef: 'api:default/a',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'g',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'squad',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'ownerOf',
|
||||
targetRef: 'component:default/s',
|
||||
},
|
||||
{
|
||||
type: 'ownerOf',
|
||||
targetRef: 'component:default/w',
|
||||
},
|
||||
{
|
||||
type: 'ownerOf',
|
||||
targetRef: 'component:default/a',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: {
|
||||
name: 'a',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'grpc',
|
||||
owner: 'g',
|
||||
definition: 'mock',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'ownedBy',
|
||||
targetRef: 'group:default/g',
|
||||
},
|
||||
{
|
||||
type: 'apiProvidedBy',
|
||||
targetRef: 'component:default/c',
|
||||
},
|
||||
{
|
||||
type: 'dependencyOf',
|
||||
targetRef: 'component:default/w',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it.each([
|
||||
['s', { kind: 'component', 'spec.type': 'service' }],
|
||||
['s', { 'metadata.tags': { $all: ['java'] } }],
|
||||
['s', { 'metadata.tags': { $all: ['java', 'spring'] } }],
|
||||
['s', { 'metadata.tags': ['java', 'spring'] }],
|
||||
['', { 1: 'foo' }],
|
||||
['s,w,g,a', {}],
|
||||
['', { kind: { $unknown: 'foo' } }],
|
||||
['', { '': 'component' }],
|
||||
['s,w,g,a', Object.create({ kind: 'component' })],
|
||||
['', { 'metadata.tags': { $all: ['go'] } }],
|
||||
['', { 'metadata.tags.0': 'java' }],
|
||||
['w,g,a', { $not: { 'metadata.tags': { $all: ['java'] } } }],
|
||||
[
|
||||
's,g',
|
||||
{
|
||||
$or: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'group' }],
|
||||
},
|
||||
],
|
||||
[
|
||||
'w,a',
|
||||
{
|
||||
$nor: [
|
||||
{ kind: 'component', 'spec.type': 'service' },
|
||||
{ kind: 'group' },
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
's,w,a',
|
||||
{
|
||||
relations: {
|
||||
$elemMatch: { type: 'ownedBy', targetRef: 'group:default/g' },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'',
|
||||
{
|
||||
metadata: { $elemMatch: { name: 'a' } },
|
||||
},
|
||||
],
|
||||
['', { $unknown: 'ignored' } as unknown as EntityPredicate],
|
||||
[
|
||||
's,w',
|
||||
{ kind: 'component', 'spec.type': { $in: ['service', 'website'] } },
|
||||
],
|
||||
[
|
||||
's,w,a',
|
||||
{
|
||||
$or: [
|
||||
{
|
||||
$and: [
|
||||
{
|
||||
kind: 'component',
|
||||
'spec.type': { $in: ['service', 'website'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ $and: [{ kind: 'api', 'spec.type': 'grpc' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
['s', { kind: 'component', 'spec.type': { $in: ['service'] } }],
|
||||
['w', { kind: 'component', 'spec.type': { $nin: ['service'] } }],
|
||||
['s,w,a', { 'spec.owner': { $exists: true } }],
|
||||
['g', { 'spec.owner': { $exists: false } }],
|
||||
['s', { 'spec.type': { $eq: 'service' } }],
|
||||
['w,g,a', { 'spec.type': { $ne: 'service' } }],
|
||||
['', { 'spec.type': null }],
|
||||
[
|
||||
's,w',
|
||||
{
|
||||
kind: 'component',
|
||||
'metadata.annotations.github.com/repo': { $exists: true },
|
||||
},
|
||||
],
|
||||
])('filter entry %s', (expected, filter) => {
|
||||
const filtered = entities.filter(entity =>
|
||||
evaluateEntityPredicate(filter, entity),
|
||||
);
|
||||
expect(filtered.map(e => e.metadata.name).sort()).toEqual(
|
||||
expected.split(',').filter(Boolean).sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* 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 { JsonValue } from '@backstage/types';
|
||||
import {
|
||||
EntityPredicate,
|
||||
EntityPredicatePrimitive,
|
||||
EntityPredicateValue,
|
||||
} from './types';
|
||||
import { valueAtPath } from './valueAtPath';
|
||||
|
||||
/**
|
||||
* Convert an entity predicate to a filter function that can be used to filter entities.
|
||||
*/
|
||||
export function entityPredicateToFilterFunction<T extends JsonValue>(
|
||||
entityPredicate: EntityPredicate,
|
||||
): (value: T) => boolean {
|
||||
return value => evaluateEntityPredicate(entityPredicate, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a entity predicate against a value, typically an entity.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export function evaluateEntityPredicate(
|
||||
filter: EntityPredicate,
|
||||
value: JsonValue,
|
||||
): boolean {
|
||||
if ('$and' in filter) {
|
||||
return filter.$and.every(f => evaluateEntityPredicate(f, value));
|
||||
}
|
||||
if ('$or' in filter) {
|
||||
return filter.$or.some(f => evaluateEntityPredicate(f, value));
|
||||
}
|
||||
if ('$nor' in filter) {
|
||||
return !filter.$nor.some(f => evaluateEntityPredicate(f, value));
|
||||
}
|
||||
if ('$not' in filter) {
|
||||
return !evaluateEntityPredicate(filter.$not, value);
|
||||
}
|
||||
|
||||
for (const filterKey in filter) {
|
||||
if (!Object.hasOwn(filter, filterKey)) {
|
||||
continue;
|
||||
}
|
||||
if (filterKey.startsWith('$')) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!evaluatePredicateValue(filter[filterKey], valueAtPath(value, filterKey))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single value against a predicate value.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
function evaluatePredicateValue(
|
||||
filter: EntityPredicateValue,
|
||||
value: JsonValue | undefined,
|
||||
): boolean {
|
||||
if (typeof filter !== 'object' || filter === null || Array.isArray(filter)) {
|
||||
return valuesAreEqual(value, filter);
|
||||
}
|
||||
|
||||
if ('$elemMatch' in filter) {
|
||||
if (!Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
return value.some(v => evaluateEntityPredicate(filter.$elemMatch, v));
|
||||
}
|
||||
if ('$all' in filter) {
|
||||
if (!Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
return filter.$all.every(v => value.includes(v));
|
||||
}
|
||||
if ('$in' in filter) {
|
||||
return filter.$in.includes(value as EntityPredicatePrimitive);
|
||||
}
|
||||
if ('$nin' in filter) {
|
||||
return !filter.$nin.includes(value as EntityPredicatePrimitive);
|
||||
}
|
||||
if ('$exists' in filter) {
|
||||
if (filter.$exists === true) {
|
||||
return value !== undefined;
|
||||
}
|
||||
return value === undefined;
|
||||
}
|
||||
if ('$eq' in filter) {
|
||||
return valuesAreEqual(value, filter.$eq);
|
||||
}
|
||||
if ('$ne' in filter) {
|
||||
return !valuesAreEqual(value, filter.$ne);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function valuesAreEqual(
|
||||
a: JsonValue | undefined,
|
||||
b: JsonValue | undefined,
|
||||
): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (typeof a === 'string' && typeof b === 'string') {
|
||||
return a.toLocaleUpperCase('en-US') === b.toLocaleUpperCase('en-US');
|
||||
}
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
return a.length === b.length && a.every((v, i) => valuesAreEqual(v, b[i]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export type {
|
||||
EntityPredicate,
|
||||
EntityPredicateExpression,
|
||||
EntityPredicatePrimitive,
|
||||
EntityPredicateValue,
|
||||
} from './types';
|
||||
export {
|
||||
evaluateEntityPredicate,
|
||||
entityPredicateToFilterFunction,
|
||||
} from './evaluateEntityPredicate';
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** @alpha */
|
||||
export type EntityPredicate =
|
||||
| EntityPredicateExpression
|
||||
| { $and: EntityPredicate[] }
|
||||
| { $or: EntityPredicate[] }
|
||||
| { $nor: EntityPredicate[] }
|
||||
| { $not: EntityPredicate };
|
||||
|
||||
/** @alpha */
|
||||
export type EntityPredicateExpression = {
|
||||
[KPath in string]: EntityPredicateValue;
|
||||
} & {
|
||||
[KPath in `$${string}`]: never;
|
||||
};
|
||||
|
||||
/** @alpha */
|
||||
export type EntityPredicateValue =
|
||||
| EntityPredicatePrimitive
|
||||
| { $exists: boolean }
|
||||
| { $eq: EntityPredicatePrimitive }
|
||||
| { $ne: EntityPredicatePrimitive }
|
||||
| { $in: EntityPredicatePrimitive[] }
|
||||
| { $nin: EntityPredicatePrimitive[] }
|
||||
| { $all: EntityPredicatePrimitive[] }
|
||||
| { $elemMatch: EntityPredicateExpression };
|
||||
|
||||
/** @alpha */
|
||||
export type EntityPredicatePrimitive = string | number | boolean;
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 { valueAtPath } from './valueAtPath';
|
||||
|
||||
describe('valueAtPath', () => {
|
||||
const subject = {
|
||||
name: 'Test',
|
||||
fields: {
|
||||
value: 123,
|
||||
tags: ['production', 'beta'],
|
||||
array: [1, 2, { nested: 'value' }],
|
||||
nested: {
|
||||
level: 1,
|
||||
deeper: {
|
||||
level: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
mixed: {
|
||||
'foo.bar.baz': 1,
|
||||
foo: { 'bar.baz': 3, bar: { baz: 4, qux: 4 } },
|
||||
'foo.bar': { baz: 2, qux: 2, quux: 2 },
|
||||
annotations: {
|
||||
'example.com/description': 'A test subject',
|
||||
'long.domain.example.com/custom': 'long',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it.each([
|
||||
['name', 'Test'],
|
||||
['unknown', undefined],
|
||||
['fields.value', 123],
|
||||
['fields.tags', ['production', 'beta']],
|
||||
['fields.array', [1, 2, { nested: 'value' }]],
|
||||
['fields.array.0', undefined], // Arrays are not traversed
|
||||
['fields.array.2', undefined], // Arrays are not traversed
|
||||
['fields.array.2.nested', undefined], // Arrays are not traversed
|
||||
['fields.nested.level', 1],
|
||||
['fields.nested.deeper.level', 2],
|
||||
['mixed.foo.bar.baz', 1], // First one wins
|
||||
['mixed.foo.bar.qux', 4], // First one wins
|
||||
['mixed.foo.bar.quux', 2], // Should not get stuck in earlier partial matches
|
||||
['mixed.annotations.example.com/description', 'A test subject'],
|
||||
['mixed.annotations.long.domain.example.com/custom', 'long'],
|
||||
])(`should find value at path %s`, (path, expected) => {
|
||||
expect(valueAtPath(subject, path)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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 { JsonValue } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* Looks up a value by path in a nested object structure.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The path should be a dot-separated string of keys to traverse. The traversal
|
||||
* will tolerate object keys containing dots, and will keep searching until a
|
||||
* value has been found or all matching keys have been traversed.
|
||||
*
|
||||
* This lookup does not traverse into arrays, returning `undefined` instead.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function valueAtPath(
|
||||
value: JsonValue | undefined,
|
||||
path: string,
|
||||
): JsonValue | undefined {
|
||||
if (!path) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const valueKey in value) {
|
||||
if (!Object.hasOwn(value, valueKey)) {
|
||||
continue;
|
||||
}
|
||||
if (valueKey === path) {
|
||||
if (value[valueKey] !== undefined) {
|
||||
return value[valueKey];
|
||||
}
|
||||
}
|
||||
if (path.startsWith(`${valueKey}.`)) {
|
||||
const found = valueAtPath(
|
||||
value[valueKey],
|
||||
path.slice(valueKey.length + 1),
|
||||
);
|
||||
if (found !== undefined) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user