catalog-react: add initial MongoDB-based entity predicates

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-02-20 11:51:37 +01:00
parent ebb68be1bd
commit c216b1a6f5
15 changed files with 776 additions and 34 deletions
+2 -1
View File
@@ -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));
}
}
+1
View File
@@ -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;
}
+1
View File
@@ -6344,6 +6344,7 @@ __metadata:
react-use: ^17.2.4
yaml: ^2.0.0
zen-observable: ^0.10.0
zod: ^3.22.4
peerDependencies:
"@types/react": ^17.0.0 || ^18.0.0
react: ^17.0.0 || ^18.0.0