-
Notifications
You must be signed in to change notification settings - Fork 522
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(notifications): initial rule engine (#5783)
Initial code for a rule engine to determine whether or not to show an in-IDE notification. A notification is a JSON payload with a set amount of criteria. The rule engine accepts context from the currently running extension then determines if the notification payload's criteria will fit the provided context. The types match the commonly designed schema, but may change in future commits. Future work: - More docs - Updates to types and/or criteria - Code that will use this --- <!--- REMINDER: Ensure that your PR meets the guidelines in CONTRIBUTING.md --> License: I confirm that my contribution is made under the terms of the Apache 2.0 license.
- Loading branch information
Showing
4 changed files
with
697 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
/*! | ||
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
import * as semver from 'semver' | ||
import globals from '../shared/extensionGlobals' | ||
import { ConditionalClause, RuleContext, DisplayIf, CriteriaCondition, ToolkitNotification } from './types' | ||
|
||
/** | ||
* Evaluates if a given version fits into the parameters specified by a notification, e.g: | ||
* | ||
* extensionVersion: { | ||
* type: 'range', | ||
* lowerInclusive: '1.21.0' | ||
* } | ||
* | ||
* will match all versions 1.21.0 and up. | ||
* | ||
* @param version the version to check | ||
* @param condition the condition to check against | ||
* @returns true if the version satisfies the condition | ||
*/ | ||
function isValidVersion(version: string, condition: ConditionalClause): boolean { | ||
switch (condition.type) { | ||
case 'range': { | ||
const lowerConstraint = !condition.lowerInclusive || semver.gte(version, condition.lowerInclusive) | ||
const upperConstraint = !condition.upperExclusive || semver.lt(version, condition.upperExclusive) | ||
return lowerConstraint && upperConstraint | ||
} | ||
case 'exactMatch': | ||
return condition.values.some((v) => semver.eq(v, version)) | ||
case 'or': | ||
/** Check case where any of the subconditions are true, i.e. one of multiple range or exactMatch conditions */ | ||
return condition.clauses.some((clause) => isValidVersion(version, clause)) | ||
default: | ||
throw new Error(`Unknown clause type: ${(condition as any).type}`) | ||
} | ||
} | ||
|
||
/** | ||
* Determine whether or not to display a given notification based on whether the | ||
* notification requirements fit the extension context provided on initialization. | ||
* | ||
* Usage: | ||
* const myContext = { | ||
* extensionVersion: '4.5.6', | ||
* ... | ||
* } | ||
* | ||
* const ruleEngine = new RuleEngine(myContext) | ||
* | ||
* notifications.forEach(n => { | ||
* if (ruleEngine.shouldDisplayNotification(n)) { | ||
* // process notification | ||
* ... | ||
* } | ||
* }) | ||
* | ||
*/ | ||
export class RuleEngine { | ||
constructor(private readonly context: RuleContext) {} | ||
|
||
public shouldDisplayNotification(payload: ToolkitNotification) { | ||
return this.evaluate(payload.displayIf) | ||
} | ||
|
||
private evaluate(condition: DisplayIf): boolean { | ||
if (condition.extensionId !== globals.context.extension.id) { | ||
return false | ||
} | ||
|
||
if (condition.ideVersion) { | ||
if (!isValidVersion(this.context.ideVersion, condition.ideVersion)) { | ||
return false | ||
} | ||
} | ||
if (condition.extensionVersion) { | ||
if (!isValidVersion(this.context.extensionVersion, condition.extensionVersion)) { | ||
return false | ||
} | ||
} | ||
|
||
if (condition.additionalCriteria) { | ||
for (const criteria of condition.additionalCriteria) { | ||
if (!this.evaluateRule(criteria)) { | ||
return false | ||
} | ||
} | ||
} | ||
|
||
return true | ||
} | ||
|
||
private evaluateRule(criteria: CriteriaCondition) { | ||
const expected = criteria.values | ||
const expectedSet = new Set(expected) | ||
|
||
const isExpected = (i: string) => expectedSet.has(i) | ||
const hasAnyOfExpected = (i: string[]) => i.some((v) => expectedSet.has(v)) | ||
const isSuperSetOfExpected = (i: string[]) => { | ||
const s = new Set(i) | ||
return expected.every((v) => s.has(v)) | ||
} | ||
const isEqualSetToExpected = (i: string[]) => { | ||
const s = new Set(i) | ||
return expected.every((v) => s.has(v)) && i.every((v) => expectedSet.has(v)) | ||
} | ||
|
||
// Maybe we could abstract these out into some strategy pattern with classes. | ||
// But this list is short and its unclear if we need to expand it further. | ||
// Also, we might replace this with a common implementation amongst the toolkits. | ||
// So... YAGNI | ||
switch (criteria.type) { | ||
case 'OS': | ||
return isExpected(this.context.os) | ||
case 'ComputeEnv': | ||
return isExpected(this.context.computeEnv) | ||
case 'AuthType': | ||
return hasAnyOfExpected(this.context.authTypes) | ||
case 'AuthRegion': | ||
return hasAnyOfExpected(this.context.authRegions) | ||
case 'AuthState': | ||
return hasAnyOfExpected(this.context.authStates) | ||
case 'AuthScopes': | ||
return isEqualSetToExpected(this.context.authScopes) | ||
case 'InstalledExtensions': | ||
return isSuperSetOfExpected(this.context.installedExtensions) | ||
case 'ActiveExtensions': | ||
return isSuperSetOfExpected(this.context.activeExtensions) | ||
default: | ||
throw new Error(`Unknown criteria type: ${criteria.type}`) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
/*! | ||
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
import * as vscode from 'vscode' | ||
import { EnvType, OperatingSystem } from '../shared/telemetry/util' | ||
|
||
/** Types of information that we can use to determine whether to show a notification or not. */ | ||
export type Criteria = | ||
| 'OS' | ||
| 'ComputeEnv' | ||
| 'AuthType' | ||
| 'AuthRegion' | ||
| 'AuthState' | ||
| 'AuthScopes' | ||
| 'InstalledExtensions' | ||
| 'ActiveExtensions' | ||
|
||
/** Generic condition where the type determines how the values are evaluated. */ | ||
export interface CriteriaCondition { | ||
readonly type: Criteria | ||
readonly values: string[] | ||
} | ||
|
||
/** One of the subconditions (clauses) must match to be valid. */ | ||
export interface OR { | ||
readonly type: 'or' | ||
readonly clauses: (Range | ExactMatch)[] | ||
} | ||
|
||
/** Version must be within the bounds to be valid. Missing bound indicates that bound is open-ended. */ | ||
export interface Range { | ||
readonly type: 'range' | ||
readonly lowerInclusive?: string // null means "-inf" | ||
readonly upperExclusive?: string // null means "+inf" | ||
} | ||
|
||
/** Version must be equal. */ | ||
export interface ExactMatch { | ||
readonly type: 'exactMatch' | ||
readonly values: string[] | ||
} | ||
|
||
export type ConditionalClause = Range | ExactMatch | OR | ||
|
||
/** How to display the notification. */ | ||
export interface UIRenderInstructions { | ||
content: { | ||
[`en-US`]: { | ||
title: string | ||
description: string | ||
} | ||
} | ||
// TODO actions | ||
} | ||
|
||
/** Condition/criteria section of a notification. */ | ||
export interface DisplayIf { | ||
extensionId: string | ||
ideVersion?: ConditionalClause | ||
extensionVersion?: ConditionalClause | ||
additionalCriteria?: CriteriaCondition[] | ||
} | ||
|
||
export interface ToolkitNotification { | ||
id: string | ||
displayIf: DisplayIf | ||
uiRenderInstructions: UIRenderInstructions | ||
} | ||
|
||
export interface Notifications { | ||
schemaVersion: string | ||
notifications: ToolkitNotification[] | ||
} | ||
|
||
export interface RuleContext { | ||
readonly ideVersion: typeof vscode.version | ||
readonly extensionVersion: string | ||
readonly os: OperatingSystem | ||
readonly computeEnv: EnvType | ||
readonly authTypes: string[] | ||
readonly authRegions: string[] | ||
readonly authStates: string[] | ||
readonly authScopes: string[] | ||
readonly installedExtensions: string[] | ||
readonly activeExtensions: string[] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.