diff --git a/.changeset/spicy-moons-poke.md b/.changeset/spicy-moons-poke.md new file mode 100644 index 0000000000..cc39d629bf --- /dev/null +++ b/.changeset/spicy-moons-poke.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-tech-insights-backend-module-jsonfc': patch +--- + +ability to add custom operators diff --git a/plugins/tech-insights-backend-module-jsonfc/README.md b/plugins/tech-insights-backend-module-jsonfc/README.md index 108b1c8564..aab57fae91 100644 --- a/plugins/tech-insights-backend-module-jsonfc/README.md +++ b/plugins/tech-insights-backend-module-jsonfc/README.md @@ -85,3 +85,34 @@ export const exampleCheck: TechInsightJsonRuleCheck = { }, }; ``` + +# Custom operators + +json-rules-engine supports a limited [number of built-in operators](https://github.com/CacheControl/json-rules-engine/blob/master/docs/rules.md#operators) that can be used in conditions. You can add your own operators by adding them to the `operators` array in the `JsonRulesEngineFactCheckerFactory` constructor. For example: + +```diff ++ import { Operator } from 'json-rules-engine'; + +const myFactCheckerFactory = new JsonRulesEngineFactCheckerFactory({ + checks: [], + logger, ++ operators: [ new Operator("startsWith", (a, b) => a.startsWith(b) ] +}) +``` + +And you can then use it in your checks like this: + +```js +... +rule: { + conditions: { + any: [ + { + fact: 'version', + operator: 'startsWith', + value: '12', + }, + ], + }, +} +``` diff --git a/plugins/tech-insights-backend-module-jsonfc/api-report.md b/plugins/tech-insights-backend-module-jsonfc/api-report.md index 7093fbfc2b..2e62e7d6a0 100644 --- a/plugins/tech-insights-backend-module-jsonfc/api-report.md +++ b/plugins/tech-insights-backend-module-jsonfc/api-report.md @@ -8,6 +8,7 @@ import { CheckResponse } from '@backstage/plugin-tech-insights-common'; import { CheckValidationResponse } from '@backstage/plugin-tech-insights-node'; import { FactChecker } from '@backstage/plugin-tech-insights-node'; import { Logger as Logger_2 } from 'winston'; +import { Operator } from 'json-rules-engine'; import { TechInsightCheck } from '@backstage/plugin-tech-insights-node'; import { TechInsightCheckRegistry } from '@backstage/plugin-tech-insights-node'; import { TechInsightsStore } from '@backstage/plugin-tech-insights-node'; @@ -50,6 +51,7 @@ export class JsonRulesEngineFactChecker repository, logger, checkRegistry, + operators, }: JsonRulesEngineFactCheckerOptions); // (undocumented) getChecks(): Promise; @@ -68,6 +70,7 @@ export class JsonRulesEngineFactCheckerFactory { checks, logger, checkRegistry, + operators, }: JsonRulesEngineFactCheckerFactoryOptions); // (undocumented) construct(repository: TechInsightsStore): JsonRulesEngineFactChecker; @@ -78,6 +81,7 @@ export type JsonRulesEngineFactCheckerFactoryOptions = { checks: TechInsightJsonRuleCheck[]; logger: Logger_2; checkRegistry?: TechInsightCheckRegistry; + operators?: Operator[]; }; // @public @@ -86,6 +90,7 @@ export type JsonRulesEngineFactCheckerOptions = { repository: TechInsightsStore; logger: Logger_2; checkRegistry?: TechInsightCheckRegistry; + operators?: Operator[]; }; // @public (undocumented) diff --git a/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.test.ts b/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.test.ts index 5c33e9e369..c808713c3b 100644 --- a/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.test.ts +++ b/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.test.ts @@ -24,6 +24,7 @@ import { } from '../index'; import { getVoidLogger } from '@backstage/backend-common'; import { TechInsightJsonRuleCheck } from '../types'; +import { Operator } from 'json-rules-engine'; const testChecks: Record = { broken: [ @@ -127,6 +128,49 @@ const testChecks: Record = { }, }, ], + + customOperator: [ + { + id: 'customOperatorTestCheck', + name: 'customOperatorTestCheck', + type: JSON_RULE_ENGINE_CHECK_TYPE, + description: 'Check For Testing using Custom Operator', + factIds: ['test-factretriever'], + rule: { + conditions: { + all: [ + { + fact: 'testnumberfact', + operator: 'isDivisibleBy', + value: 2, + }, + ], + }, + }, + }, + ], + + invalidCustomOperator: [ + { + id: 'invalidCustomOperatorTestCheck', + name: 'invalidCustomOperatorTestCheck', + type: JSON_RULE_ENGINE_CHECK_TYPE, + description: + 'Check For Testing using a Custom Operator that is not registered', + factIds: ['test-factretriever'], + rule: { + conditions: { + all: [ + { + fact: 'testnumberfact', + operator: 'isOdd', + value: 2, + }, + ], + }, + }, + }, + ], }; const latestSchemasMock = jest.fn().mockImplementation(() => [ @@ -166,6 +210,9 @@ describe('JsonRulesEngineFactChecker', () => { const factChecker = new JsonRulesEngineFactCheckerFactory({ checkRegistry: mockCheckRegistry, checks: [], + operators: [ + new Operator('isDivisibleBy', (a, b) => a % b === 0), + ], logger: getVoidLogger(), }).construct(mockRepository); @@ -234,6 +281,42 @@ describe('JsonRulesEngineFactChecker', () => { }); }); + it('should use custom operators when defined', async () => { + const results = await factChecker.runChecks('a/a/a', ['customOperator']); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + facts: { + testnumberfact: { + value: 3, + type: 'integer', + description: '', + }, + }, + result: false, + check: { + id: 'customOperatorTestCheck', + type: JSON_RULE_ENGINE_CHECK_TYPE, + name: 'customOperatorTestCheck', + description: 'Check For Testing using Custom Operator', + factIds: ['test-factretriever'], + rule: { + conditions: { + all: [ + { + fact: 'testnumberfact', + factResult: 3, + operator: 'isDivisibleBy', + result: false, + value: 2, + }, + ], + priority: 1, + }, + }, + }, + }); + }); + it('should gracefully handle multiple check at once', async () => { const results = await factChecker.runChecks('a/a/a', [ 'simple', @@ -307,17 +390,22 @@ describe('JsonRulesEngineFactChecker', () => { }); describe('when validating checks', () => { - it('should succeed on valid rules', async () => { - const validationResponse = await factChecker.validate( - testChecks.simple[0], - ); - expect(validationResponse.valid).toBeTruthy(); - }); - it('should fail on broken rules', async () => { - const validationResponse = await factChecker.validate( - testChecks.broken[0], - ); - expect(validationResponse.valid).toBeFalsy(); + [testChecks.simple[0], testChecks.customOperator[0]].forEach(check => { + it(`should succeed on valid rule: ${check.name}`, async () => { + const validationResponse = await factChecker.validate(check); + expect(validationResponse.valid).toBeTruthy(); + }); }); + + [testChecks.broken[0], testChecks.invalidCustomOperator[0]].forEach( + check => { + it(`should fail on broken rules: ${check.name}`, async () => { + const validationResponse = await factChecker.validate( + testChecks.broken[0], + ); + expect(validationResponse.valid).toBeFalsy(); + }); + }, + ); }); }); diff --git a/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.ts b/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.ts index 2623a5c2ee..aef09d5434 100644 --- a/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.ts +++ b/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.ts @@ -23,11 +23,16 @@ import { CheckValidationResponse, } from '@backstage/plugin-tech-insights-node'; import { FactResponse } from '@backstage/plugin-tech-insights-common'; -import { Engine, EngineResult, TopLevelCondition } from 'json-rules-engine'; +import { + Engine, + EngineResult, + Operator, + TopLevelCondition, +} from 'json-rules-engine'; import { DefaultCheckRegistry } from './CheckRegistry'; import { Logger } from 'winston'; import { pick } from 'lodash'; -import Ajv from 'ajv'; +import Ajv, { SchemaObject } from 'ajv'; import * as validationSchema from './validation-schema.json'; import { JSON_RULE_ENGINE_CHECK_TYPE } from '../constants'; @@ -46,6 +51,7 @@ export type JsonRulesEngineFactCheckerOptions = { repository: TechInsightsStore; logger: Logger; checkRegistry?: TechInsightCheckRegistry; + operators?: Operator[]; }; /** @@ -60,15 +66,27 @@ export class JsonRulesEngineFactChecker private readonly checkRegistry: TechInsightCheckRegistry; private repository: TechInsightsStore; private readonly logger: Logger; + private readonly validationSchema: SchemaObject; + private readonly operators: Operator[]; constructor({ checks, repository, logger, checkRegistry, + operators, }: JsonRulesEngineFactCheckerOptions) { this.repository = repository; this.logger = logger; + this.operators = operators || []; + this.validationSchema = JSON.parse(JSON.stringify(validationSchema)); + + this.operators.forEach(op => { + this.validationSchema.definitions.condition.properties.operator.anyOf.push( + { const: op.name }, + ); + }); + checks.forEach(check => this.validate(check)); this.checkRegistry = checkRegistry ?? @@ -80,6 +98,10 @@ export class JsonRulesEngineFactChecker checks?: string[], ): Promise { const engine = new Engine(); + this.operators.forEach(op => { + engine.addOperator(op); + }); + const techInsightChecks = checks ? await this.checkRegistry.getAll(checks) : await this.checkRegistry.list(); @@ -125,7 +147,7 @@ export class JsonRulesEngineFactChecker check: TechInsightJsonRuleCheck, ): Promise { const ajv = new Ajv({ verbose: true }); - const validator = ajv.compile(validationSchema); + const validator = ajv.compile(this.validationSchema); const isValidToSchema = validator(check.rule); if (check.type !== JSON_RULE_ENGINE_CHECK_TYPE) { const msg = `Only ${JSON_RULE_ENGINE_CHECK_TYPE} checks can be registered to this fact checker`; @@ -317,6 +339,7 @@ export type JsonRulesEngineFactCheckerFactoryOptions = { checks: TechInsightJsonRuleCheck[]; logger: Logger; checkRegistry?: TechInsightCheckRegistry; + operators?: Operator[]; }; /** @@ -330,15 +353,18 @@ export class JsonRulesEngineFactCheckerFactory { private readonly checks: TechInsightJsonRuleCheck[]; private readonly logger: Logger; private readonly checkRegistry?: TechInsightCheckRegistry; + private readonly operators?: Operator[]; constructor({ checks, logger, checkRegistry, + operators, }: JsonRulesEngineFactCheckerFactoryOptions) { this.logger = logger; this.checks = checks; this.checkRegistry = checkRegistry; + this.operators = operators; } /** @@ -352,6 +378,7 @@ export class JsonRulesEngineFactCheckerFactory { logger: this.logger, checkRegistry: this.checkRegistry, repository, + operators: this.operators, }); } }