diff --git a/plugins/catalog-common/api-report-alpha.md b/plugins/catalog-common/api-report-alpha.md index 573b35d3b3..bd36b499fe 100644 --- a/plugins/catalog-common/api-report-alpha.md +++ b/plugins/catalog-common/api-report-alpha.md @@ -42,6 +42,21 @@ export const catalogPermissions: ( // @alpha export function parseFilterExpression( expression: string, + options?: { + onParseError?: ( + error: Error, + context: { + expression: string; + }, + ) => void; + onEvaluateError?: ( + error: Error, + context: { + expression: string; + entity: Entity; + }, + ) => void; + }, ): (entity: Entity) => boolean; // @alpha diff --git a/plugins/catalog-common/src/filter/matrchers/createHasMatcher.test.ts b/plugins/catalog-common/src/filter/matrchers/createHasMatcher.test.ts new file mode 100644 index 0000000000..dbce5025b6 --- /dev/null +++ b/plugins/catalog-common/src/filter/matrchers/createHasMatcher.test.ts @@ -0,0 +1,109 @@ +/* + * 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: {}, + annotations: {}, + links: [], + }, + } 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; + + 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(['annotations'], err)(empty1)).toBe(false); + expect(createHasMatcher(['annotations'], err)(empty2)).toBe(false); + expect(createHasMatcher(['annotations'], err)(annotations)).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(createHasMatcher(['labels', 'links'], err)(annotations)).toBe(false); + + 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','annotations','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','annotations','links'`, + }), + ); + expect(err).toHaveBeenCalledWith( + expect.objectContaining({ + message: `'bar' is not a valid parameter for 'has' filter expressions, expected one of 'labels','annotations','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-common/src/filter/matrchers/createHasMatcher.ts b/plugins/catalog-common/src/filter/matrchers/createHasMatcher.ts index 7b9eebba1c..f57316a65e 100644 --- a/plugins/catalog-common/src/filter/matrchers/createHasMatcher.ts +++ b/plugins/catalog-common/src/filter/matrchers/createHasMatcher.ts @@ -20,7 +20,10 @@ import { EntityMatcherFn } from './types'; /** * Matches on the non-empty presence of different parts of the entity */ -export function createHasMatcher(parameters: string[]): EntityMatcherFn { +export function createHasMatcher( + parameters: string[], + onParseError: (error: Error) => void, +): EntityMatcherFn { const allowedMatchers: Record = { labels: entity => { return Object.keys(entity.metadata.labels ?? {}).length > 0; @@ -33,16 +36,20 @@ export function createHasMatcher(parameters: string[]): EntityMatcherFn { }, }; - const matchers = parameters.map(parameter => { + const matchers = parameters.flatMap(parameter => { const matcher = allowedMatchers[parameter.toLocaleLowerCase('en-US')]; if (!matcher) { const known = Object.keys(allowedMatchers).map(m => `'${m}'`); - throw new InputError( - `'${parameter}' is not a valid parameter for 'has' filter expressions, expected one of ${known}`, + onParseError( + new InputError( + `'${parameter}' is not a valid parameter for 'has' filter expressions, expected one of ${known}`, + ), ); + return []; } - return matcher; + return [matcher]; }); - return entity => matchers.some(matcher => matcher(entity)); + return entity => + matchers.length ? matchers.some(matcher => matcher(entity)) : true; } diff --git a/plugins/catalog-common/src/filter/matrchers/createIsMatcher.test.ts b/plugins/catalog-common/src/filter/matrchers/createIsMatcher.test.ts new file mode 100644 index 0000000000..4cb3094d62 --- /dev/null +++ b/plugins/catalog-common/src/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-common/src/filter/matrchers/createIsMatcher.ts b/plugins/catalog-common/src/filter/matrchers/createIsMatcher.ts index 3802d6dbce..30bfe4eb15 100644 --- a/plugins/catalog-common/src/filter/matrchers/createIsMatcher.ts +++ b/plugins/catalog-common/src/filter/matrchers/createIsMatcher.ts @@ -20,22 +20,29 @@ import { EntityMatcherFn } from './types'; /** * Matches on different semantic properties of the entity */ -export function createIsMatcher(parameters: string[]): EntityMatcherFn { +export function createIsMatcher( + parameters: string[], + onParseError: (error: Error) => void, +): EntityMatcherFn { const allowedMatchers: Record = { orphan: entity => Boolean(entity.metadata.annotations?.['backstage.io/orphan']), }; - const matchers = parameters.map(parameter => { + const matchers = parameters.flatMap(parameter => { const matcher = allowedMatchers[parameter.toLocaleLowerCase('en-US')]; if (!matcher) { const known = Object.keys(allowedMatchers).map(m => `'${m}'`); - throw new InputError( - `'${parameter}' is not a valid parameter for 'is' filter expressions, expected one of ${known}`, + onParseError( + new InputError( + `'${parameter}' is not a valid parameter for 'is' filter expressions, expected one of ${known}`, + ), ); + return []; } - return matcher; + return [matcher]; }); - return entity => matchers.some(matcher => matcher(entity)); + return entity => + matchers.length ? matchers.some(matcher => matcher(entity)) : true; } diff --git a/plugins/catalog-common/src/filter/matrchers/createKindMatcher.test.ts b/plugins/catalog-common/src/filter/matrchers/createKindMatcher.test.ts new file mode 100644 index 0000000000..f109acccd4 --- /dev/null +++ b/plugins/catalog-common/src/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-common/src/filter/matrchers/createKindMatcher.ts b/plugins/catalog-common/src/filter/matrchers/createKindMatcher.ts index 071746453e..4f7c475ebe 100644 --- a/plugins/catalog-common/src/filter/matrchers/createKindMatcher.ts +++ b/plugins/catalog-common/src/filter/matrchers/createKindMatcher.ts @@ -19,7 +19,10 @@ import { EntityMatcherFn } from './types'; /** * Matches on kind */ -export function createKindMatcher(parameters: string[]): EntityMatcherFn { +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-common/src/filter/matrchers/createTypeMatcher.test.ts b/plugins/catalog-common/src/filter/matrchers/createTypeMatcher.test.ts new file mode 100644 index 0000000000..31c9af7e11 --- /dev/null +++ b/plugins/catalog-common/src/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-common/src/filter/matrchers/createTypeMatcher.ts b/plugins/catalog-common/src/filter/matrchers/createTypeMatcher.ts index d0e0f39c98..31f7a5a461 100644 --- a/plugins/catalog-common/src/filter/matrchers/createTypeMatcher.ts +++ b/plugins/catalog-common/src/filter/matrchers/createTypeMatcher.ts @@ -19,7 +19,10 @@ import { EntityMatcherFn } from './types'; /** * Matches on spec.type */ -export function createTypeMatcher(parameters: string[]): EntityMatcherFn { +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; diff --git a/plugins/catalog-common/src/filter/parseFilterExpression.test.ts b/plugins/catalog-common/src/filter/parseFilterExpression.test.ts index d8543c4e10..a53b84646e 100644 --- a/plugins/catalog-common/src/filter/parseFilterExpression.test.ts +++ b/plugins/catalog-common/src/filter/parseFilterExpression.test.ts @@ -21,19 +21,34 @@ import { } from './parseFilterExpression'; describe('parseFilterExpression', () => { + function run(expression: string) { + return parseFilterExpression(expression, { + onParseError(e: Error) { + throw e; + }, + }); + } + 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(parseFilterExpression('kind:component')(component)).toBe(true); - expect(parseFilterExpression('kind:componenT')(component)).toBe(true); - expect(parseFilterExpression('kind:user')(component)).toBe(false); + expect(run('kind:user,component')(user)).toBe(true); + expect(run('kind:user,component')(component)).toBe(true); + expect(run('kind:user,component')(resource)).toBe(false); + }); - // match ANY of the parameters - expect(parseFilterExpression('kind:user,component')(user)).toBe(true); - expect(parseFilterExpression('kind:user,component')(component)).toBe(true); - expect(parseFilterExpression('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', () => { @@ -46,123 +61,90 @@ describe('parseFilterExpression', () => { }, } as unknown as Entity; - expect(parseFilterExpression('is:orphan')(empty)).toBe(false); - expect(parseFilterExpression('is:orphan')(orphan)).toBe(true); + expect(run('is:orphan')(empty)).toBe(false); + expect(run('is:orphan')(orphan)).toBe(true); - expect(() => - parseFilterExpression('is:orphan,bar'), - ).toThrowErrorMatchingInlineSnapshot( + 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 empty1 = { + const empty = { metadata: {}, } as unknown as Entity; - const empty2 = { - metadata: { - labels: {}, - annotations: {}, - links: [], - }, - } as unknown as Entity; const labels = { - metadata: { - labels: { a: 'b' }, - }, + metadata: { labels: { a: 'b' } }, } as unknown as Entity; const annotations = { - metadata: { - annotations: { a: 'b' }, - }, + metadata: { annotations: { a: 'b' } }, } as unknown as Entity; const links = { metadata: { links: [{}] }, } as unknown as Entity; - expect(parseFilterExpression('has:labels')(empty1)).toBe(false); - expect(parseFilterExpression('has:labels')(empty2)).toBe(false); - expect(parseFilterExpression('has:labels')(labels)).toBe(true); + 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(parseFilterExpression('has:annotations')(empty1)).toBe(false); - expect(parseFilterExpression('has:annotations')(empty2)).toBe(false); - expect(parseFilterExpression('has:annotations')(annotations)).toBe(true); - - expect(parseFilterExpression('has:links')(empty1)).toBe(false); - expect(parseFilterExpression('has:links')(empty2)).toBe(false); - expect(parseFilterExpression('has:links')(links)).toBe(true); - - // match ANY of the parameters - expect(parseFilterExpression('has:labels,links')(empty1)).toBe(false); - expect(parseFilterExpression('has:labels,links')(empty2)).toBe(false); - expect(parseFilterExpression('has:labels,links')(labels)).toBe(true); - expect(parseFilterExpression('has:labels,links')(links)).toBe(true); - expect(parseFilterExpression('has:labels,links')(annotations)).toBe(false); - - expect(() => - parseFilterExpression('has:labels,bar'), - ).toThrowErrorMatchingInlineSnapshot( + expect(() => run('has:labels,bar')).toThrowErrorMatchingInlineSnapshot( `"'bar' is not a valid parameter for 'has' filter expressions, expected one of 'labels','annotations','links'"`, ); }); it('rejects unknown keys', () => { - expect(() => - parseFilterExpression('unknown:foo'), - ).toThrowErrorMatchingInlineSnapshot( + 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(() => parseFilterExpression(':')).toThrowErrorMatchingInlineSnapshot( + expect(() => run(':')).toThrowErrorMatchingInlineSnapshot( `"':' is not a valid filter expression, expected 'key:parameter' form"`, ); - expect(() => - parseFilterExpression(':a'), - ).toThrowErrorMatchingInlineSnapshot( + expect(() => run(':a')).toThrowErrorMatchingInlineSnapshot( `"':a' is not a valid filter expression, expected 'key:parameter' form"`, ); - expect(() => - parseFilterExpression('a:'), - ).toThrowErrorMatchingInlineSnapshot( + 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(splitFilterExpression('')).toEqual([]); - expect(splitFilterExpression(' ')).toEqual([]); - expect(splitFilterExpression('kind:component')).toEqual([ + expect(run('')).toEqual([]); + expect(run(' ')).toEqual([]); + expect(run('kind:component')).toEqual([ { key: 'kind', parameters: ['component'] }, ]); - expect(splitFilterExpression('kind:component,user')).toEqual([ + expect(run('kind:component,user')).toEqual([ { key: 'kind', parameters: ['component', 'user'] }, ]); - expect(splitFilterExpression('kind:component,user type:foo')).toEqual([ + expect(run('kind:component,user type:foo')).toEqual([ { key: 'kind', parameters: ['component', 'user'] }, { key: 'type', parameters: ['foo'] }, ]); - expect(splitFilterExpression('with:multiple:colons')).toEqual([ + expect(run('with:multiple:colons')).toEqual([ { key: 'with', parameters: ['multiple:colons'] }, ]); }); it('rejects malformed inputs', () => { - expect(() => splitFilterExpression(':')).toThrowErrorMatchingInlineSnapshot( + expect(() => run(':')).toThrowErrorMatchingInlineSnapshot( `"':' is not a valid filter expression, expected 'key:parameter' form"`, ); - expect(() => - splitFilterExpression(':a'), - ).toThrowErrorMatchingInlineSnapshot( + expect(() => run(':a')).toThrowErrorMatchingInlineSnapshot( `"':a' is not a valid filter expression, expected 'key:parameter' form"`, ); - expect(() => - splitFilterExpression('a:'), - ).toThrowErrorMatchingInlineSnapshot( + expect(() => run('a:')).toThrowErrorMatchingInlineSnapshot( `"'a:' is not a valid filter expression, expected 'key:parameter' form"`, ); }); diff --git a/plugins/catalog-common/src/filter/parseFilterExpression.ts b/plugins/catalog-common/src/filter/parseFilterExpression.ts index d2b46f582e..ffd25d75d3 100644 --- a/plugins/catalog-common/src/filter/parseFilterExpression.ts +++ b/plugins/catalog-common/src/filter/parseFilterExpression.ts @@ -24,7 +24,10 @@ import { createHasMatcher } from './matrchers/createHasMatcher'; const rootMatcherFactories: Record< string, - (parameters: string[]) => EntityMatcherFn + ( + parameters: string[], + onParseError: (error: Error) => void, + ) => EntityMatcherFn > = { kind: createKindMatcher, type: createTypeMatcher, @@ -43,11 +46,39 @@ const rootMatcherFactories: Record< * 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 `onParseError` callback is called whenever an error was encountered + * during initial parsing of the expression. If the callback throws, + * `parseFilterExpression` throws that same error. If the callback does not + * throw, the part of the input expression that had an error is ignored entirely + * and parsing continues. + * + * The `onEvaluateError` callback is called whenever an error was encountered + * during evaluation of the returned function. If the callback throws, the + * evaluation call throws the same error. If the callback does not throw, the + * whole evaluation returns false. */ export function parseFilterExpression( expression: string, + options?: { + onParseError?: ( + error: Error, + context: { + expression: string; + }, + ) => void; + onEvaluateError?: ( + error: Error, + context: { + expression: string; + entity: Entity; + }, + ) => void; + }, ): (entity: Entity) => boolean { - const parts = splitFilterExpression(expression); + const parts = splitFilterExpression(expression, e => + options?.onParseError?.(e, { expression }), + ); const matchers = parts.map(part => { const factory = rootMatcherFactories[part.key]; @@ -57,14 +88,17 @@ export function parseFilterExpression( `'${part.key}' is not a valid filter expression key, expected one of ${known}`, ); } - return factory(part.parameters); + return factory(part.parameters, e => + options?.onParseError?.(e, { expression }), + ); }); return (entity: Entity) => { return matchers.every(matcher => { try { return matcher(entity); - } catch { + } catch (e) { + options?.onEvaluateError?.(e, { expression, entity }); return false; } }); @@ -73,6 +107,7 @@ export function parseFilterExpression( export function splitFilterExpression( expression: string, + onParseError: (error: Error) => void, ): Array<{ key: string; parameters: string[] }> { const words = expression .split(' ') @@ -84,13 +119,16 @@ export function splitFilterExpression( for (const word of words) { const match = word.match(/^([^:]+):(.+)$/); if (!match) { - throw new InputError( - `'${word}' is not a valid filter expression, expected 'key:parameter' form`, + onParseError( + new InputError( + `'${word}' is not a valid filter expression, expected 'key:parameter' form`, + ), ); + continue; } const key = match[1]; - const parameters = match[2].split(','); + const parameters = match[2].split(',').filter(Boolean); // silently ignore double commas result.push({ key, parameters }); } diff --git a/plugins/catalog/src/alpha/EntityOverviewPage.tsx b/plugins/catalog/src/alpha/EntityOverviewPage.tsx index 02ec044975..5ef6b9a2e5 100644 --- a/plugins/catalog/src/alpha/EntityOverviewPage.tsx +++ b/plugins/catalog/src/alpha/EntityOverviewPage.tsx @@ -40,10 +40,21 @@ function CardWrapper(props: { } else if (typeof filter === 'function') { return subject => filter(subject); } - return parseFilterExpression(filter); + return parseFilterExpression(filter, { + onParseError(_error) { + // ignore silently + }, + onEvaluateError(_error) { + // ignore silently + }, + }); }, [filter]); - return filterFn(entity) ? <>{element} : null; + return filterFn(entity) ? ( + + {element} + + ) : null; } export function EntityOverviewPage(props: EntityOverviewPageProps) { @@ -51,13 +62,12 @@ export function EntityOverviewPage(props: EntityOverviewPageProps) { return ( {props.cards.map((card, index) => ( - - - + ))} );