diff --git a/.changeset/thick-snails-travel.md b/.changeset/thick-snails-travel.md new file mode 100644 index 0000000000..bfd14d55e1 --- /dev/null +++ b/.changeset/thick-snails-travel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': minor +--- + +Properly support both function- and string-form visibility filter expressions in the new extensions exported via `/alpha`. diff --git a/.changeset/thirty-fireants-cheer.md b/.changeset/thirty-fireants-cheer.md new file mode 100644 index 0000000000..abda872570 --- /dev/null +++ b/.changeset/thirty-fireants-cheer.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Breaking alpha-API change to entity visibility filter functions to accept a bare entity as their first argument, instead of an object with an entity property. + +Functions that accept such filters now also support the string expression form of filters. diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index a46e119ac0..34cd78d480 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -14,8 +14,7 @@ app: - entity.cards.labels - entity.cards.links: config: - filter: - - isKind: component + filter: kind:component has:links # Entity page content - entity.content.techdocs diff --git a/plugins/catalog-react/api-report-alpha.md b/plugins/catalog-react/api-report-alpha.md index ed851f810a..913055f8b0 100644 --- a/plugins/catalog-react/api-report-alpha.md +++ b/plugins/catalog-react/api-report-alpha.md @@ -24,17 +24,14 @@ export function createEntityCardExtension< }; disabled?: boolean; inputs?: TInputs; - filter?: (ctx: { entity: Entity }) => boolean; + filter?: + | typeof entityFilterFunctionExtensionDataRef.T + | typeof entityFilterExpressionExtensionDataRef.T; loader: (options: { inputs: Expand>; }) => Promise; }): Extension<{ - filter?: - | { - isKind?: string | undefined; - isType?: string | undefined; - }[] - | undefined; + filter?: string | undefined; }>; // @alpha (undocumented) @@ -51,19 +48,16 @@ export function createEntityContentExtension< routeRef?: RouteRef; defaultPath: string; defaultTitle: string; - filter?: (ctx: { entity: Entity }) => boolean; + filter?: + | typeof entityFilterFunctionExtensionDataRef.T + | typeof entityFilterExpressionExtensionDataRef.T; loader: (options: { inputs: Expand>; }) => Promise; }): Extension<{ title: string; path: string; - filter?: - | { - isKind?: string | undefined; - isType?: string | undefined; - }[] - | undefined; + filter?: string | undefined; }>; // @alpha (undocumented) @@ -73,8 +67,14 @@ export const entityContentTitleExtensionDataRef: ConfigurableExtensionDataRef< >; // @alpha (undocumented) -export const entityFilterExtensionDataRef: ConfigurableExtensionDataRef< - (ctx: { entity: Entity }) => boolean, +export const entityFilterExpressionExtensionDataRef: ConfigurableExtensionDataRef< + string, + {} +>; + +// @alpha (undocumented) +export const entityFilterFunctionExtensionDataRef: ConfigurableExtensionDataRef< + (entity: Entity) => boolean, {} >; diff --git a/plugins/catalog-react/src/alpha.tsx b/plugins/catalog-react/src/alpha.tsx index a339816742..43b7156b34 100644 --- a/plugins/catalog-react/src/alpha.tsx +++ b/plugins/catalog-react/src/alpha.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React, { lazy } from 'react'; import { AnyExtensionInputMap, ExtensionBoundary, @@ -25,53 +24,26 @@ import { createExtensionDataRef, createSchemaFromZod, } from '@backstage/frontend-plugin-api'; +import React, { lazy } from 'react'; +import { Entity } from '@backstage/catalog-model'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { Expand } from '../../../packages/frontend-plugin-api/src/types'; -import { Entity } from '@backstage/catalog-model'; -export { isOwnerOf } from './utils'; export { useEntityPermission } from './hooks/useEntityPermission'; +export { isOwnerOf } from './utils'; /** @alpha */ export const entityContentTitleExtensionDataRef = createExtensionDataRef('plugin.catalog.entity.content.title'); /** @alpha */ -export const entityFilterExtensionDataRef = createExtensionDataRef< - (ctx: { entity: Entity }) => boolean ->('plugin.catalog.entity.filter'); +export const entityFilterFunctionExtensionDataRef = createExtensionDataRef< + (entity: Entity) => boolean +>('plugin.catalog.entity.filter.fn'); -function applyFilter(a?: string, b?: string): boolean { - if (!a) { - return true; - } - return a.toLocaleLowerCase('en-US') === b?.toLocaleLowerCase('en-US'); -} - -// TODO: Only two hardcoded isKind and isType filters are available for now -// This is just an initial config filter implementation and needs to be revisited -function buildFilter( - config: { filter?: { isKind?: string; isType?: string }[] }, - filterFunc?: (ctx: { entity: Entity }) => boolean, -) { - return (ctx: { entity: Entity }) => { - const configuredFilterMatch = config.filter?.some(filter => { - const kindMatch = applyFilter(filter.isKind, ctx.entity.kind); - const typeMatch = applyFilter( - filter.isType, - ctx.entity.spec?.type?.toString(), - ); - return kindMatch && typeMatch; - }); - if (configuredFilterMatch) { - return true; - } - if (filterFunc) { - return filterFunc(ctx); - } - return true; - }; -} +/** @alpha */ +export const entityFilterExpressionExtensionDataRef = + createExtensionDataRef('plugin.catalog.entity.filter.expression'); // TODO: Figure out how to merge with provided config schema /** @alpha */ @@ -82,7 +54,9 @@ export function createEntityCardExtension< attachTo?: { id: string; input: string }; disabled?: boolean; inputs?: TInputs; - filter?: (ctx: { entity: Entity }) => boolean; + filter?: + | typeof entityFilterFunctionExtensionDataRef.T + | typeof entityFilterExpressionExtensionDataRef.T; loader: (options: { inputs: Expand>; }) => Promise; @@ -98,19 +72,13 @@ export function createEntityCardExtension< disabled: options.disabled ?? true, output: { element: coreExtensionData.reactElement, - filter: entityFilterExtensionDataRef, + filterFunction: entityFilterFunctionExtensionDataRef.optional(), + filterExpression: entityFilterExpressionExtensionDataRef.optional(), }, inputs: options.inputs, configSchema: createSchemaFromZod(z => z.object({ - filter: z - .array( - z.object({ - isKind: z.string().optional(), - isType: z.string().optional(), - }), - ) - .optional(), + filter: z.string().optional(), }), ), factory({ config, inputs, node }) { @@ -126,7 +94,7 @@ export function createEntityCardExtension< ), - filter: buildFilter(config, options.filter), + ...mergeFilters({ config, options }), }; }, }); @@ -143,7 +111,9 @@ export function createEntityContentExtension< routeRef?: RouteRef; defaultPath: string; defaultTitle: string; - filter?: (ctx: { entity: Entity }) => boolean; + filter?: + | typeof entityFilterFunctionExtensionDataRef.T + | typeof entityFilterExpressionExtensionDataRef.T; loader: (options: { inputs: Expand>; }) => Promise; @@ -162,21 +132,15 @@ export function createEntityContentExtension< path: coreExtensionData.routePath, routeRef: coreExtensionData.routeRef.optional(), title: entityContentTitleExtensionDataRef, - filter: entityFilterExtensionDataRef, + filterFunction: entityFilterFunctionExtensionDataRef.optional(), + filterExpression: entityFilterExpressionExtensionDataRef.optional(), }, inputs: options.inputs, configSchema: createSchemaFromZod(z => z.object({ path: z.string().default(options.defaultPath), title: z.string().default(options.defaultTitle), - filter: z - .array( - z.object({ - isKind: z.string().optional(), - isType: z.string().optional(), - }), - ) - .optional(), + filter: z.string().optional(), }), ), factory({ config, inputs, node }) { @@ -195,8 +159,35 @@ export function createEntityContentExtension< ), - filter: buildFilter(config, options.filter), + ...mergeFilters({ config, options }), }; }, }); } + +/** + * Decides what filter outputs to produce, given some options and config + */ +function mergeFilters(inputs: { + options: { + filter?: + | typeof entityFilterFunctionExtensionDataRef.T + | typeof entityFilterExpressionExtensionDataRef.T; + }; + config: { + filter?: string; + }; +}): { + filterFunction?: typeof entityFilterFunctionExtensionDataRef.T; + filterExpression?: typeof entityFilterExpressionExtensionDataRef.T; +} { + const { options, config } = inputs; + if (config.filter) { + return { filterExpression: config.filter }; + } else if (typeof options.filter === 'string') { + return { filterExpression: options.filter }; + } else if (typeof options.filter === 'function') { + return { filterFunction: options.filter }; + } + return {}; +} diff --git a/plugins/catalog/src/alpha/EntityOverviewPage.tsx b/plugins/catalog/src/alpha/EntityOverviewPage.tsx index baa93845ae..932e13d962 100644 --- a/plugins/catalog/src/alpha/EntityOverviewPage.tsx +++ b/plugins/catalog/src/alpha/EntityOverviewPage.tsx @@ -14,29 +14,95 @@ * limitations under the License. */ -import React from 'react'; -import { useEntity } from '@backstage/plugin-catalog-react'; import { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import Grid from '@material-ui/core/Grid'; +import React, { useMemo } from 'react'; +import { parseFilterExpression } from './filter/parseFilterExpression'; interface EntityOverviewPageProps { cards: Array<{ element: React.JSX.Element; - filter: (ctx: { entity: Entity }) => boolean; + filterFunction?: (entity: Entity) => boolean; + filterExpression?: string; }>; } +// Keeps track of what filter expression strings that we've seen duplicates of +// with functions, or which emitted parsing errors for so far +const seenParseErrorExpressionStrings = new Set(); +const seenDuplicateExpressionStrings = new Set(); + +// Given an optional filter function and an optional filter expression, make +// sure that at most one of them was given, and return a filter function that +// does the right thing. +function buildFilterFn( + filterFunction?: (entity: Entity) => boolean, + filterExpression?: string, +): (entity: Entity) => boolean { + if ( + filterFunction && + filterExpression && + !seenDuplicateExpressionStrings.has(filterExpression) + ) { + // eslint-disable-next-line no-console + console.warn( + `Duplicate entity filter methods found, both '${filterExpression}' as well as a callback function, which is not permitted - using the callback`, + ); + seenDuplicateExpressionStrings.add(filterExpression); + } + + const filter = filterFunction || filterExpression; + if (!filter) { + return () => true; + } else if (typeof filter === 'function') { + return subject => filter(subject); + } + + const result = parseFilterExpression(filter); + if ( + result.expressionParseErrors.length && + !seenParseErrorExpressionStrings.has(filter) + ) { + // eslint-disable-next-line no-console + console.warn( + `Error(s) in entity filter expression '${filter}'`, + result.expressionParseErrors, + ); + seenParseErrorExpressionStrings.add(filter); + } + + return result.filterFn; +} + +// Handles the memoized parsing of filter expressions for each card +function CardWrapper(props: { + entity: Entity; + element: React.JSX.Element; + filterFunction?: (entity: Entity) => boolean; + filterExpression?: string; +}) { + const { entity, element, filterFunction, filterExpression } = props; + + const filterFn = useMemo( + () => buildFilterFn(filterFunction, filterExpression), + [filterFunction, filterExpression], + ); + + return filterFn(entity) ? ( + + {element} + + ) : null; +} + export function EntityOverviewPage(props: EntityOverviewPageProps) { const { entity } = useEntity(); return ( - {props.cards - .filter(card => card.filter({ entity })) - .map(card => ( - - {card.element} - - ))} + {props.cards.map((card, index) => ( + + ))} ); } diff --git a/plugins/catalog/src/alpha/entityCards.tsx b/plugins/catalog/src/alpha/entityCards.tsx index c7b75679a7..572e2caa3c 100644 --- a/plugins/catalog/src/alpha/entityCards.tsx +++ b/plugins/catalog/src/alpha/entityCards.tsx @@ -27,7 +27,7 @@ export const EntityAboutCard = createEntityCardExtension({ export const EntityLinksCard = createEntityCardExtension({ id: 'links', - filter: ({ entity }) => Boolean(entity.metadata.links), + filter: 'has:links', loader: async () => import('../components/EntityLinksCard').then(m => { return ; @@ -36,7 +36,7 @@ export const EntityLinksCard = createEntityCardExtension({ export const EntityLabelsCard = createEntityCardExtension({ id: 'labels', - filter: ({ entity }) => Boolean(entity.metadata.labels), + filter: 'has:labels', loader: async () => import('../components/EntityLabelsCard').then(m => ( diff --git a/plugins/catalog/src/alpha/entityContents.tsx b/plugins/catalog/src/alpha/entityContents.tsx index 9bef4a5e0d..e280326b02 100644 --- a/plugins/catalog/src/alpha/entityContents.tsx +++ b/plugins/catalog/src/alpha/entityContents.tsx @@ -21,7 +21,8 @@ import { } from '@backstage/frontend-plugin-api'; import { createEntityContentExtension, - entityFilterExtensionDataRef, + entityFilterFunctionExtensionDataRef, + entityFilterExpressionExtensionDataRef, } from '@backstage/plugin-catalog-react/alpha'; export const OverviewEntityContent = createEntityContentExtension({ @@ -32,7 +33,8 @@ export const OverviewEntityContent = createEntityContentExtension({ inputs: { cards: createExtensionInput({ element: coreExtensionData.reactElement, - filter: entityFilterExtensionDataRef, + filterFunction: entityFilterFunctionExtensionDataRef.optional(), + filterExpression: entityFilterExpressionExtensionDataRef.optional(), }), }, loader: async ({ inputs }) => diff --git a/plugins/catalog/src/alpha/filter/matrchers/createHasMatcher.test.ts b/plugins/catalog/src/alpha/filter/matrchers/createHasMatcher.test.ts new file mode 100644 index 0000000000..53524d228e --- /dev/null +++ b/plugins/catalog/src/alpha/filter/matrchers/createHasMatcher.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2023 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 { Entity } from '@backstage/catalog-model'; +import { createHasMatcher } from './createHasMatcher'; + +describe('createHasMatcher', () => { + const empty1 = { + metadata: {}, + } as unknown as Entity; + const empty2 = { + metadata: { + labels: {}, + links: [], + }, + } as unknown as Entity; + const labels = { + metadata: { + labels: { a: 'b' }, + }, + } as unknown as Entity; + const links = { + metadata: { links: [{}] }, + } as unknown as Entity; + + const err = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('supports valid parameters', () => { + expect(createHasMatcher(['labels'], err)(empty1)).toBe(false); + expect(createHasMatcher(['labels'], err)(empty2)).toBe(false); + expect(createHasMatcher(['labels'], err)(labels)).toBe(true); + + expect(createHasMatcher(['links'], err)(empty1)).toBe(false); + expect(createHasMatcher(['links'], err)(empty2)).toBe(false); + expect(createHasMatcher(['links'], err)(links)).toBe(true); + + expect(err).not.toHaveBeenCalled(); + }); + + it('matches if ANY parameter matches', () => { + expect(createHasMatcher(['labels', 'links'], err)(empty1)).toBe(false); + expect(createHasMatcher(['labels', 'links'], err)(empty2)).toBe(false); + expect(createHasMatcher(['labels', 'links'], err)(labels)).toBe(true); + expect(createHasMatcher(['labels', 'links'], err)(links)).toBe(true); + + expect(err).not.toHaveBeenCalled(); + }); + + it('emits errors properly, and skips over bad parameters', () => { + // throw if callback throws + expect(() => + createHasMatcher(['labels', 'bar'], e => { + throw e; + }), + ).toThrowErrorMatchingInlineSnapshot( + `"'bar' is not a valid parameter for 'has' filter expressions, expected one of 'labels','links'"`, + ); + expect(err).not.toHaveBeenCalled(); + + // continue if callback does not throw, and skip over the bad parameter + let matcher = createHasMatcher(['labels', 'foo', 'bar'], err); + expect(err).toHaveBeenCalledTimes(2); + expect(err).toHaveBeenCalledWith( + expect.objectContaining({ + message: `'foo' is not a valid parameter for 'has' filter expressions, expected one of 'labels','links'`, + }), + ); + expect(err).toHaveBeenCalledWith( + expect.objectContaining({ + message: `'bar' is not a valid parameter for 'has' filter expressions, expected one of 'labels','links'`, + }), + ); + expect(matcher(empty1)).toBe(false); + expect(matcher(labels)).toBe(true); + + // when no parameters at all match, just return true + matcher = createHasMatcher(['foo', 'bar'], err); + expect(matcher(empty1)).toBe(true); + expect(matcher(labels)).toBe(true); + }); +}); diff --git a/plugins/catalog/src/alpha/filter/matrchers/createHasMatcher.ts b/plugins/catalog/src/alpha/filter/matrchers/createHasMatcher.ts new file mode 100644 index 0000000000..9fd393a561 --- /dev/null +++ b/plugins/catalog/src/alpha/filter/matrchers/createHasMatcher.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2023 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 { InputError } from '@backstage/errors'; +import { EntityMatcherFn } from './types'; + +const allowedMatchers: Record = { + labels: entity => { + return Object.keys(entity.metadata.labels ?? {}).length > 0; + }, + links: entity => { + return (entity.metadata.links ?? []).length > 0; + }, +}; + +/** + * Matches on the non-empty presence of different parts of the entity + */ +export function createHasMatcher( + parameters: string[], + onParseError: (error: Error) => void, +): EntityMatcherFn { + const matchers = parameters.flatMap(parameter => { + const matcher = allowedMatchers[parameter.toLocaleLowerCase('en-US')]; + if (!matcher) { + const known = Object.keys(allowedMatchers).map(m => `'${m}'`); + onParseError( + new InputError( + `'${parameter}' is not a valid parameter for 'has' filter expressions, expected one of ${known}`, + ), + ); + return []; + } + return [matcher]; + }); + + return entity => + matchers.length ? matchers.some(matcher => matcher(entity)) : true; +} diff --git a/plugins/catalog/src/alpha/filter/matrchers/createIsMatcher.test.ts b/plugins/catalog/src/alpha/filter/matrchers/createIsMatcher.test.ts new file mode 100644 index 0000000000..4cb3094d62 --- /dev/null +++ b/plugins/catalog/src/alpha/filter/matrchers/createIsMatcher.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2023 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 { Entity } from '@backstage/catalog-model'; +import { createIsMatcher } from './createIsMatcher'; + +describe('createIsMatcher', () => { + const empty = { + metadata: {}, + } as unknown as Entity; + const orphan = { + metadata: { + annotations: { ['backstage.io/orphan']: 'true' }, + }, + } as unknown as Entity; + + const err = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('supports valid parameters', () => { + expect(createIsMatcher(['orphan'], err)(empty)).toBe(false); + expect(createIsMatcher(['orphan'], err)(orphan)).toBe(true); + + expect(err).not.toHaveBeenCalled(); + }); + + it('emits errors properly, and skips over bad parameters', () => { + // throw if callback throws + expect(() => + createIsMatcher(['orphan', 'foo'], e => { + throw e; + }), + ).toThrowErrorMatchingInlineSnapshot( + `"'foo' is not a valid parameter for 'is' filter expressions, expected one of 'orphan'"`, + ); + expect(err).not.toHaveBeenCalled(); + + // continue if callback does not throw, and skip over the bad parameter + let matcher = createIsMatcher(['orphan', 'foo', 'bar'], err); + expect(err).toHaveBeenCalledTimes(2); + expect(err).toHaveBeenCalledWith( + expect.objectContaining({ + message: `'foo' is not a valid parameter for 'is' filter expressions, expected one of 'orphan'`, + }), + ); + expect(err).toHaveBeenCalledWith( + expect.objectContaining({ + message: `'bar' is not a valid parameter for 'is' filter expressions, expected one of 'orphan'`, + }), + ); + expect(matcher(empty)).toBe(false); + expect(matcher(orphan)).toBe(true); + + // when no parameters at all match, just return true + matcher = createIsMatcher(['foo', 'bar'], err); + expect(matcher(empty)).toBe(true); + expect(matcher(orphan)).toBe(true); + }); +}); diff --git a/plugins/catalog/src/alpha/filter/matrchers/createIsMatcher.ts b/plugins/catalog/src/alpha/filter/matrchers/createIsMatcher.ts new file mode 100644 index 0000000000..49a54349d1 --- /dev/null +++ b/plugins/catalog/src/alpha/filter/matrchers/createIsMatcher.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2023 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 { InputError } from '@backstage/errors'; +import { EntityMatcherFn } from './types'; + +const allowedMatchers: Record = { + orphan: entity => + Boolean(entity.metadata.annotations?.['backstage.io/orphan']), +}; + +/** + * Matches on different semantic properties of the entity + */ +export function createIsMatcher( + parameters: string[], + onParseError: (error: Error) => void, +): EntityMatcherFn { + const matchers = parameters.flatMap(parameter => { + const matcher = allowedMatchers[parameter.toLocaleLowerCase('en-US')]; + if (!matcher) { + const known = Object.keys(allowedMatchers).map(m => `'${m}'`); + onParseError( + new InputError( + `'${parameter}' is not a valid parameter for 'is' filter expressions, expected one of ${known}`, + ), + ); + return []; + } + return [matcher]; + }); + + return entity => + matchers.length ? matchers.some(matcher => matcher(entity)) : true; +} diff --git a/plugins/catalog/src/alpha/filter/matrchers/createKindMatcher.test.ts b/plugins/catalog/src/alpha/filter/matrchers/createKindMatcher.test.ts new file mode 100644 index 0000000000..f109acccd4 --- /dev/null +++ b/plugins/catalog/src/alpha/filter/matrchers/createKindMatcher.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2023 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 { Entity } from '@backstage/catalog-model'; +import { createKindMatcher } from './createKindMatcher'; + +describe('createKindMatcher', () => { + const component = { kind: 'Component' } as unknown as Entity; + const user = { kind: 'User' } as unknown as Entity; + const resource = { kind: 'Resource' } as unknown as Entity; + + const err = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('supports valid parameters', () => { + expect(createKindMatcher(['component'], err)(component)).toBe(true); + expect(createKindMatcher(['componenT'], err)(component)).toBe(true); + expect(createKindMatcher(['user'], err)(component)).toBe(false); + + expect(err).not.toHaveBeenCalled(); + }); + + it('matches if ANY parameter matches', () => { + expect(createKindMatcher(['user', 'component'], err)(user)).toBe(true); + expect(createKindMatcher(['user', 'component'], err)(component)).toBe(true); + expect(createKindMatcher(['user', 'component'], err)(resource)).toBe(false); + + expect(err).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/catalog/src/alpha/filter/matrchers/createKindMatcher.ts b/plugins/catalog/src/alpha/filter/matrchers/createKindMatcher.ts new file mode 100644 index 0000000000..4f7c475ebe --- /dev/null +++ b/plugins/catalog/src/alpha/filter/matrchers/createKindMatcher.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2023 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 { EntityMatcherFn } from './types'; + +/** + * Matches on kind + */ +export function createKindMatcher( + parameters: string[], + _onParseError: (error: Error) => void, +): EntityMatcherFn { + const items = parameters.map(p => p.toLocaleLowerCase('en-US')); + return entity => items.includes(entity.kind.toLocaleLowerCase('en-US')); +} diff --git a/plugins/catalog/src/alpha/filter/matrchers/createTypeMatcher.test.ts b/plugins/catalog/src/alpha/filter/matrchers/createTypeMatcher.test.ts new file mode 100644 index 0000000000..31c9af7e11 --- /dev/null +++ b/plugins/catalog/src/alpha/filter/matrchers/createTypeMatcher.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2023 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 { Entity } from '@backstage/catalog-model'; +import { createTypeMatcher } from './createTypeMatcher'; + +describe('createTypeMatcher', () => { + const empty1 = {} as unknown as Entity; + const empty2 = { spec: {} } as unknown as Entity; + const service = { spec: { type: 'service' } } as unknown as Entity; + const website = { spec: { type: 'website' } } as unknown as Entity; + const pipeline = { spec: { type: 'pipeline' } } as unknown as Entity; + + const err = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('supports valid parameters', () => { + expect(createTypeMatcher(['service'], err)(empty1)).toBe(false); + expect(createTypeMatcher(['service'], err)(empty2)).toBe(false); + + expect(createTypeMatcher(['service'], err)(service)).toBe(true); + expect(createTypeMatcher(['sErViCe'], err)(service)).toBe(true); + expect(createTypeMatcher(['service'], err)(service)).toBe(true); + expect(createTypeMatcher(['website'], err)(service)).toBe(false); + + expect(err).not.toHaveBeenCalled(); + }); + + it('matches if ANY parameter matches', () => { + expect(createTypeMatcher(['service', 'website'], err)(service)).toBe(true); + expect(createTypeMatcher(['service', 'website'], err)(website)).toBe(true); + expect(createTypeMatcher(['service', 'website'], err)(pipeline)).toBe( + false, + ); + + expect(err).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/catalog/src/alpha/filter/matrchers/createTypeMatcher.ts b/plugins/catalog/src/alpha/filter/matrchers/createTypeMatcher.ts new file mode 100644 index 0000000000..31f7a5a461 --- /dev/null +++ b/plugins/catalog/src/alpha/filter/matrchers/createTypeMatcher.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2023 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 { EntityMatcherFn } from './types'; + +/** + * Matches on spec.type + */ +export function createTypeMatcher( + parameters: string[], + _onParseError: (error: Error) => void, +): EntityMatcherFn { + const items = parameters.map(p => p.toLocaleLowerCase('en-US')); + return entity => { + const value = entity.spec?.type; + return ( + typeof value === 'string' && + items.includes(value.toLocaleLowerCase('en-US')) + ); + }; +} diff --git a/plugins/catalog/src/alpha/filter/matrchers/types.ts b/plugins/catalog/src/alpha/filter/matrchers/types.ts new file mode 100644 index 0000000000..0fff91b3d5 --- /dev/null +++ b/plugins/catalog/src/alpha/filter/matrchers/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2023 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 { Entity } from '@backstage/catalog-model'; + +export type EntityMatcherFn = (entity: Entity) => boolean; diff --git a/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts b/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts new file mode 100644 index 0000000000..616091739e --- /dev/null +++ b/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2023 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 { Entity } from '@backstage/catalog-model'; +import { + parseFilterExpression, + splitFilterExpression, +} from './parseFilterExpression'; + +describe('parseFilterExpression', () => { + function run(expression: string) { + const result = parseFilterExpression(expression); + if (result.expressionParseErrors.length) { + throw result.expressionParseErrors[0]; + } + return result.filterFn; + } + + it('supports "kind" expressions', () => { + const component = { kind: 'Component' } as unknown as Entity; + const user = { kind: 'User' } as unknown as Entity; + const resource = { kind: 'Resource' } as unknown as Entity; + + expect(run('kind:user,component')(user)).toBe(true); + expect(run('kind:user,component')(component)).toBe(true); + expect(run('kind:user,component')(resource)).toBe(false); + }); + + it('supports "type" expressions', () => { + const empty = {} as unknown as Entity; + const service = { spec: { type: 'service' } } as unknown as Entity; + const website = { spec: { type: 'website' } } as unknown as Entity; + const pipeline = { spec: { type: 'pipeline' } } as unknown as Entity; + + expect(run('type:service,website')(empty)).toBe(false); + expect(run('type:service,website')(service)).toBe(true); + expect(run('type:service,website')(website)).toBe(true); + expect(run('type:service,website')(pipeline)).toBe(false); + }); + + it('supports "is" expressions', () => { + const empty = { + metadata: {}, + } as unknown as Entity; + const orphan = { + metadata: { + annotations: { ['backstage.io/orphan']: 'true' }, + }, + } as unknown as Entity; + + expect(run('is:orphan')(empty)).toBe(false); + expect(run('is:orphan')(orphan)).toBe(true); + + expect(() => run('is:orphan,bar')).toThrowErrorMatchingInlineSnapshot( + `"'bar' is not a valid parameter for 'is' filter expressions, expected one of 'orphan'"`, + ); + }); + + it('supports "has" expressions', () => { + const empty = { + metadata: {}, + } as unknown as Entity; + const labels = { + metadata: { labels: { a: 'b' } }, + } as unknown as Entity; + const annotations = { + metadata: { annotations: { a: 'b' } }, + } as unknown as Entity; + const links = { + metadata: { links: [{}] }, + } as unknown as Entity; + + expect(run('has:labels,links')(empty)).toBe(false); + expect(run('has:labels,links')(labels)).toBe(true); + expect(run('has:labels,links')(links)).toBe(true); + expect(run('has:labels,links')(annotations)).toBe(false); + + expect(() => run('has:labels,bar')).toThrowErrorMatchingInlineSnapshot( + `"'bar' is not a valid parameter for 'has' filter expressions, expected one of 'labels','links'"`, + ); + }); + + it('rejects unknown keys', () => { + expect(() => run('unknown:foo')).toThrowErrorMatchingInlineSnapshot( + `"'unknown' is not a valid filter expression key, expected one of 'kind','type','is','has'"`, + ); + }); + + it('rejects malformed inputs', () => { + expect(() => run(':')).toThrowErrorMatchingInlineSnapshot( + `"':' is not a valid filter expression, expected 'key:parameter' form"`, + ); + expect(() => run(':a')).toThrowErrorMatchingInlineSnapshot( + `"':a' is not a valid filter expression, expected 'key:parameter' form"`, + ); + expect(() => run('a:')).toThrowErrorMatchingInlineSnapshot( + `"'a:' is not a valid filter expression, expected 'key:parameter' form"`, + ); + }); +}); + +describe('splitFilterExpression', () => { + function run(expression: string) { + return splitFilterExpression(expression, e => { + throw e; + }); + } + + it('properly splits into expression atoms', () => { + expect(run('')).toEqual([]); + expect(run(' ')).toEqual([]); + expect(run('kind:component')).toEqual([ + { key: 'kind', parameters: ['component'] }, + ]); + expect(run('kind:component,user')).toEqual([ + { key: 'kind', parameters: ['component', 'user'] }, + ]); + expect(run('kind:component,user type:foo')).toEqual([ + { key: 'kind', parameters: ['component', 'user'] }, + { key: 'type', parameters: ['foo'] }, + ]); + expect(run('with:multiple:colons')).toEqual([ + { key: 'with', parameters: ['multiple:colons'] }, + ]); + }); + + it('rejects malformed inputs', () => { + expect(() => run(':')).toThrowErrorMatchingInlineSnapshot( + `"':' is not a valid filter expression, expected 'key:parameter' form"`, + ); + expect(() => run(':a')).toThrowErrorMatchingInlineSnapshot( + `"':a' is not a valid filter expression, expected 'key:parameter' form"`, + ); + expect(() => run('a:')).toThrowErrorMatchingInlineSnapshot( + `"'a:' is not a valid filter expression, expected 'key:parameter' form"`, + ); + }); +}); diff --git a/plugins/catalog/src/alpha/filter/parseFilterExpression.ts b/plugins/catalog/src/alpha/filter/parseFilterExpression.ts new file mode 100644 index 0000000000..a4c17cb8da --- /dev/null +++ b/plugins/catalog/src/alpha/filter/parseFilterExpression.ts @@ -0,0 +1,126 @@ +/* + * Copyright 2023 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 { Entity } from '@backstage/catalog-model'; +import { InputError } from '@backstage/errors'; +import { EntityMatcherFn } from './matrchers/types'; +import { createKindMatcher } from './matrchers/createKindMatcher'; +import { createTypeMatcher } from './matrchers/createTypeMatcher'; +import { createIsMatcher } from './matrchers/createIsMatcher'; +import { createHasMatcher } from './matrchers/createHasMatcher'; + +const rootMatcherFactories: Record< + string, + ( + parameters: string[], + onParseError: (error: Error) => void, + ) => EntityMatcherFn +> = { + kind: createKindMatcher, + type: createTypeMatcher, + is: createIsMatcher, + has: createHasMatcher, +}; + +/** + * Parses a filter expression that decides whether to render an entity component + * or not. Returns a function that matches entities based on that expression. + * + * @remarks + * + * Filter strings are on the form `kind:user,group is:orphan`. There's + * effectively an AND between the space separated parts, and an OR between comma + * separated parameters. So the example filter string semantically means + * "entities that are of either User or Group kind, and also are orphans". + * + * The `expressionParseErrors` array contains any errors that were encountered + * during initial parsing of the expression. Note that the parts of the input + * expression that had errors are ignored entirely and parsing continues as if + * they didn't exist. + */ +export function parseFilterExpression(expression: string): { + filterFn: (entity: Entity) => boolean; + expressionParseErrors: Error[]; +} { + const expressionParseErrors: Error[] = []; + + const parts = splitFilterExpression(expression, e => + expressionParseErrors.push(e), + ); + + const matchers = parts.flatMap(part => { + const factory = rootMatcherFactories[part.key]; + if (!factory) { + const known = Object.keys(rootMatcherFactories).map(m => `'${m}'`); + expressionParseErrors.push( + new InputError( + `'${part.key}' is not a valid filter expression key, expected one of ${known}`, + ), + ); + return []; + } + + const matcher = factory(part.parameters, e => + expressionParseErrors.push(e), + ); + return [matcher]; + }); + + const filterFn = (entity: Entity) => + matchers.every(matcher => { + try { + return matcher(entity); + } catch { + return false; + } + }); + + return { + filterFn, + expressionParseErrors, + }; +} + +export function splitFilterExpression( + expression: string, + onParseError: (error: Error) => void, +): Array<{ key: string; parameters: string[] }> { + const words = expression + .split(' ') + .map(w => w.trim()) + .filter(Boolean); + + const result = new Array<{ key: string; parameters: string[] }>(); + + for (const word of words) { + const match = word.match(/^([^:]+):(.+)$/); + if (!match) { + onParseError( + new InputError( + `'${word}' is not a valid filter expression, expected 'key:parameter' form`, + ), + ); + continue; + } + + const key = match[1]; + const parameters = match[2].split(',').filter(Boolean); // silently ignore double commas + + result.push({ key, parameters }); + } + + return result; +} diff --git a/plugins/catalog/src/alpha/plugin.tsx b/plugins/catalog/src/alpha/plugin.tsx index 7140259df0..99aacd27da 100644 --- a/plugins/catalog/src/alpha/plugin.tsx +++ b/plugins/catalog/src/alpha/plugin.tsx @@ -22,8 +22,8 @@ import { entityRouteRef } from '@backstage/plugin-catalog-react'; import { createComponentRouteRef, createFromTemplateRouteRef, - unregisterRedirectRouteRef, rootRouteRef, + unregisterRedirectRouteRef, viewTechDocRouteRef, } from '../routes';