|
| 1 | +/*! |
| 2 | + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. |
| 3 | + * SPDX-License-Identifier: Apache-2.0 |
| 4 | + */ |
| 5 | + |
| 6 | +import * as semver from 'semver' |
| 7 | +import globals from '../shared/extensionGlobals' |
| 8 | +import { ConditionalClause, RuleContext, DisplayIf, CriteriaCondition, ToolkitNotification } from './types' |
| 9 | + |
| 10 | +/** |
| 11 | + * Evaluates if a given version fits into the parameters specified by a notification, e.g: |
| 12 | + * |
| 13 | + * extensionVersion: { |
| 14 | + * type: 'range', |
| 15 | + * lowerInclusive: '1.21.0' |
| 16 | + * } |
| 17 | + * |
| 18 | + * will match all versions 1.21.0 and up. |
| 19 | + * |
| 20 | + * @param version the version to check |
| 21 | + * @param condition the condition to check against |
| 22 | + * @returns true if the version satisfies the condition |
| 23 | + */ |
| 24 | +function isValidVersion(version: string, condition: ConditionalClause): boolean { |
| 25 | + switch (condition.type) { |
| 26 | + case 'range': { |
| 27 | + const lowerConstraint = !condition.lowerInclusive || semver.gte(version, condition.lowerInclusive) |
| 28 | + const upperConstraint = !condition.upperExclusive || semver.lt(version, condition.upperExclusive) |
| 29 | + return lowerConstraint && upperConstraint |
| 30 | + } |
| 31 | + case 'exactMatch': |
| 32 | + return condition.values.some((v) => semver.eq(v, version)) |
| 33 | + case 'or': |
| 34 | + /** Check case where any of the subconditions are true, i.e. one of multiple range or exactMatch conditions */ |
| 35 | + return condition.clauses.some((clause) => isValidVersion(version, clause)) |
| 36 | + default: |
| 37 | + throw new Error(`Unknown clause type: ${(condition as any).type}`) |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +/** |
| 42 | + * Determine whether or not to display a given notification based on whether the |
| 43 | + * notification requirements fit the extension context provided on initialization. |
| 44 | + * |
| 45 | + * Usage: |
| 46 | + * const myContext = { |
| 47 | + * extensionVersion: '4.5.6', |
| 48 | + * ... |
| 49 | + * } |
| 50 | + * |
| 51 | + * const ruleEngine = new RuleEngine(myContext) |
| 52 | + * |
| 53 | + * notifications.forEach(n => { |
| 54 | + * if (ruleEngine.shouldDisplayNotification(n)) { |
| 55 | + * // process notification |
| 56 | + * ... |
| 57 | + * } |
| 58 | + * }) |
| 59 | + * |
| 60 | + */ |
| 61 | +export class RuleEngine { |
| 62 | + constructor(private readonly context: RuleContext) {} |
| 63 | + |
| 64 | + public shouldDisplayNotification(payload: ToolkitNotification) { |
| 65 | + return this.evaluate(payload.displayIf) |
| 66 | + } |
| 67 | + |
| 68 | + private evaluate(condition: DisplayIf): boolean { |
| 69 | + if (condition.extensionId !== globals.context.extension.id) { |
| 70 | + return false |
| 71 | + } |
| 72 | + |
| 73 | + if (condition.ideVersion) { |
| 74 | + if (!isValidVersion(this.context.ideVersion, condition.ideVersion)) { |
| 75 | + return false |
| 76 | + } |
| 77 | + } |
| 78 | + if (condition.extensionVersion) { |
| 79 | + if (!isValidVersion(this.context.extensionVersion, condition.extensionVersion)) { |
| 80 | + return false |
| 81 | + } |
| 82 | + } |
| 83 | + |
| 84 | + if (condition.additionalCriteria) { |
| 85 | + for (const criteria of condition.additionalCriteria) { |
| 86 | + if (!this.evaluateRule(criteria)) { |
| 87 | + return false |
| 88 | + } |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + return true |
| 93 | + } |
| 94 | + |
| 95 | + private evaluateRule(criteria: CriteriaCondition) { |
| 96 | + const expected = criteria.values |
| 97 | + const expectedSet = new Set(expected) |
| 98 | + |
| 99 | + const isExpected = (i: string) => expectedSet.has(i) |
| 100 | + const hasAnyOfExpected = (i: string[]) => i.some((v) => expectedSet.has(v)) |
| 101 | + const isSuperSetOfExpected = (i: string[]) => { |
| 102 | + const s = new Set(i) |
| 103 | + return expected.every((v) => s.has(v)) |
| 104 | + } |
| 105 | + const isEqualSetToExpected = (i: string[]) => { |
| 106 | + const s = new Set(i) |
| 107 | + return expected.every((v) => s.has(v)) && i.every((v) => expectedSet.has(v)) |
| 108 | + } |
| 109 | + |
| 110 | + // Maybe we could abstract these out into some strategy pattern with classes. |
| 111 | + // But this list is short and its unclear if we need to expand it further. |
| 112 | + // Also, we might replace this with a common implementation amongst the toolkits. |
| 113 | + // So... YAGNI |
| 114 | + switch (criteria.type) { |
| 115 | + case 'OS': |
| 116 | + return isExpected(this.context.os) |
| 117 | + case 'ComputeEnv': |
| 118 | + return isExpected(this.context.computeEnv) |
| 119 | + case 'AuthType': |
| 120 | + return hasAnyOfExpected(this.context.authTypes) |
| 121 | + case 'AuthRegion': |
| 122 | + return hasAnyOfExpected(this.context.authRegions) |
| 123 | + case 'AuthState': |
| 124 | + return hasAnyOfExpected(this.context.authStates) |
| 125 | + case 'AuthScopes': |
| 126 | + return isEqualSetToExpected(this.context.authScopes) |
| 127 | + case 'InstalledExtensions': |
| 128 | + return isSuperSetOfExpected(this.context.installedExtensions) |
| 129 | + case 'ActiveExtensions': |
| 130 | + return isSuperSetOfExpected(this.context.activeExtensions) |
| 131 | + default: |
| 132 | + throw new Error(`Unknown criteria type: ${criteria.type}`) |
| 133 | + } |
| 134 | + } |
| 135 | +} |
0 commit comments