adaptivecards-templating
Version:
Adaptive Card data binding and templating engine for JavaScript
549 lines (466 loc) • 19.6 kB
text/typescript
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import * as AEL from "adaptive-expressions";
const pkg = require('./../package.json');
class EvaluationContext {
private static readonly _reservedFields = ["$data", "$when", "$root", "$index", "$host", "$_acTemplateVersion"];
private _stateStack: Array<{ $data: any, $index: any }> = [];
private _$data: any;
$root: any;
$host: any;
$index: number;
$_acTemplateVersion: any;
constructor(context?: IEvaluationContext) {
if (context !== undefined) {
this.$_acTemplateVersion = this.generateVersionJson();
this.$root = context.$root;
this.$host = context.$host;
}
}
isReservedField(name: string): boolean {
return EvaluationContext._reservedFields.indexOf(name) >= 0;
}
saveState() {
this._stateStack.push(
{
$data: this.$data,
$index: this.$index
});
}
restoreLastState() {
if (this._stateStack.length === 0) {
throw new Error("There is no evaluation context state to restore.");
}
let savedContext = this._stateStack.pop();
this.$data = savedContext.$data;
this.$index = savedContext.$index;
}
get $data(): any {
return this._$data !== undefined ? this._$data : this.$root;
}
set $data(value: any) {
this._$data = value;
}
generateVersionJson() {
// Example version: 2.3.0-alpha
const version = pkg.version;
const versionSplit = version.split('.');
let patchSplit = [];
const patchIndex = 2;
if (versionSplit[patchIndex]) {
patchSplit = versionSplit[patchIndex].split('-');
}
return {
"major": parseInt(versionSplit[0]),
"minor": parseInt(versionSplit[1]),
"patch": parseInt(patchSplit[0]),
"suffix": patchSplit[1] || "",
}
}
}
class TemplateObjectMemory implements AEL.MemoryInterface {
private _memory: AEL.MemoryInterface;
$root: any;
$data: any;
$index: any;
$host: any;
$_acTemplateVersion: any;
constructor() {
this._memory = new AEL.SimpleObjectMemory(this);
}
getValue(path: string): any {
let actualPath = (path.length > 0 && path[0] !== "$") ? "$data." + path : path;
return this._memory.getValue(actualPath);
}
setValue(path: string, input: any) {
this._memory.setValue(path, input);
}
version(): string {
return this._memory.version();
}
}
/**
* Holds global settings that can be used to customize the way templates are expanded.
*/
export class GlobalSettings {
/**
* Callback invoked when expression evaluation needs the value of a field in the source data object
* and that field is undefined or null. By default, expression evaluation will substitute an undefined
* field with its binding expression (e.g. `${field}`). This callback makes it possible to customize that
* behavior.
*
* **Example**
* Given this data object:
*
* ```json
* {
* firstName: "David"
* }
* ```
*
* The expression `${firstName} ${lastName}` will evaluate to "David ${lastName}" because the `lastName`
* field is undefined.
*
* Now let's set the callback:
* ```typescript
* GlobalSettings.getUndefinedFieldValueSubstitutionString = (path: string) => { return "<undefined value>"; }
* ```
*
* With that, the above expression will evaluate to "David <undefined value>"
*/
static getUndefinedFieldValueSubstitutionString?: (path: string) => string | undefined = undefined;
}
/**
* Holds the context used to expand a template.
*/
export interface IEvaluationContext {
/**
* The root data object the template will bind to. Expressions that refer to $root in the template payload
* map to this field. Initially, $data also maps to $root.
*/
$root?: any;
/**
* The host data object the template will bind to. Expressions that refer to $host in the template payload
* map to this field. This allows a host process to supply additional context to the template.
*/
$host?: any;
}
/**
* Represents a template that can be bound to data.
*/
export class Template {
private templateExpansionWarnings;
private static prepare(node: any): any {
if (typeof node === "string") {
return Template.parseInterpolatedString(node);
}
else if (typeof node === "object" && node !== null) {
if (Array.isArray(node)) {
let result: any[] = [];
for (let item of node) {
result.push(Template.prepare(item));
}
return result;
}
else {
let keys = Object.keys(node);
let result = {};
for (let key of keys) {
result[key] = Template.prepare(node[key]);
}
return result;
}
}
else {
return node;
}
}
private static internalTryEvaluateExpression(expression: AEL.Expression, context: EvaluationContext, allowSubstitutions: boolean): { value: any; error: string } {
let memory = new TemplateObjectMemory();
memory.$root = context.$root;
memory.$data = context.$data;
memory.$index = context.$index;
memory.$host = context.$host;
memory.$_acTemplateVersion = context.$_acTemplateVersion;
let options: AEL.Options | undefined = undefined;
if (allowSubstitutions) {
options = new AEL.Options();
options.nullSubstitution = (path: string) => {
let substitutionValue: string | undefined = undefined;
if (GlobalSettings.getUndefinedFieldValueSubstitutionString) {
substitutionValue = GlobalSettings.getUndefinedFieldValueSubstitutionString(path);
}
return substitutionValue ? substitutionValue : "${" + path + "}";
}
}
// The root of an expression coming from an interpolated string is of type Concat.
// In that case, and if the caller allows it, we're doing our own concatenation
// in order to catch each individual expression evaluation error and substitute in
// the final string
if (expression.type === AEL.ExpressionType.Concat && allowSubstitutions) {
let result = "";
for (let childExpression of expression.children) {
let evaluationResult: { value: any; error: string };
try {
evaluationResult = childExpression.tryEvaluate(memory, options);
}
catch (ex) {
// We'll swallow all exceptions here
evaluationResult = {
value: undefined,
error: ex
};
}
if (evaluationResult.error) {
evaluationResult.value = "${" + childExpression.toString() + "}";
}
result += evaluationResult.value.toString();
}
return { value: result, error: undefined };
}
return expression.tryEvaluate(memory, options);
}
/**
* Parses an interpolated string into an Expression object ready to evaluate.
*
* @param interpolatedString The interpolated string to parse. Example: "Hello ${name}"
* @returns An Expression object if the provided interpolated string contained at least one expression (e.g. "${expression}"); the original string otherwise.
*/
public static parseInterpolatedString(interpolatedString: string): AEL.Expression | string {
let lookup: AEL.EvaluatorLookup = (type: string) => {
let standardFunction = AEL.ExpressionFunctions.standardFunctions.get(type);
if (standardFunction) {
return standardFunction;
}
else {
return new AEL.ExpressionEvaluator(
type,
(expression: AEL.Expression, state: AEL.MemoryInterface, options: AEL.Options) => { throw new Error("Unknown function " + type); },
AEL.ReturnType.String);
}
}
// If there is at least one expression start marker, let's attempt to convert into an expression
if (interpolatedString.indexOf("${") >= 0) {
let parsedExpression = AEL.Expression.parse("`" + interpolatedString + "`", lookup);
if (parsedExpression.type === "concat") {
if (parsedExpression.children.length === 1 && !(parsedExpression.children[0] instanceof AEL.Constant)) {
// The concat contains a single child that isn't a constant, thus the original
// string was a single expression. When evaluated, we want it to produce the type
// of that single expression
return parsedExpression.children[0];
}
else if (parsedExpression.children.length === 2) {
let firstChild = parsedExpression.children[0];
if (firstChild instanceof AEL.Constant && firstChild.value === "" && !(parsedExpression.children[1] instanceof AEL.Constant)) {
// The concat contains 2 children, and the first one is an empty string constant and the second isn't a constant.
// From version 4.10.3, AEL always inserts an empty string constant in all concat expression. Thus the original
// string was a single expression in this case as well. When evaluated, we want it to produce the type
// of that single expression.
return parsedExpression.children[1];
}
}
// Otherwise, we want the expression to produce a string
return parsedExpression;
}
}
// If the original string didn't contain any expression, return i as is
return interpolatedString;
}
/**
* Tries to evaluate the provided expression using the provided context.
*
* @param expression The expression to evaluate.
* @param context The context (data) used to evaluate the expression.
* @param allowSubstitutions Indicates if the expression evaluator should substitute undefined value with a default
* string or the value returned by the GlobalSettings.getUndefinedFieldValueSubstitutionString callback.
* @returns An object representing the result of the evaluation. If the evaluation succeeded, the value property
* contains the actual evaluation result, and the error property is undefined. If the evaluation fails, the error
* property contains a message detailing the error that occurred.
*/
public static tryEvaluateExpression(expression: AEL.Expression, context: IEvaluationContext, allowSubstitutions: boolean): { value: any; error: string } {
return Template.internalTryEvaluateExpression(expression, new EvaluationContext(context), allowSubstitutions);
}
private _context: EvaluationContext;
private _preparedPayload: any;
private expandSingleObject(node: object): any {
let result = {};
let keys = Object.keys(node);
for (let key of keys) {
if (!this._context.isReservedField(key)) {
let value = this.internalExpand(node[key]);
if (value !== undefined) {
result[key] = value;
}
}
}
return result;
}
private internalExpand(node: any): any {
let result: any;
this._context.saveState();
if (Array.isArray(node)) {
let itemArray: any[] = [];
for (let item of node) {
let expandedItem = this.internalExpand(item);
if (expandedItem !== null) {
if (Array.isArray(expandedItem)) {
itemArray = itemArray.concat(expandedItem);
}
else {
itemArray.push(expandedItem);
}
}
}
result = itemArray;
}
else if (node instanceof AEL.Expression) {
let evaluationResult = Template.internalTryEvaluateExpression(node, this._context, true);
if (!evaluationResult.error) {
result = evaluationResult.value;
}
else {
throw new Error(evaluationResult.error);
}
}
else if (typeof node === "object" && node !== null) {
let when = node["$when"];
let dataContext = node["$data"];
let dataContextIsArray: boolean = false;
let dataContexts: any[];
if (dataContext === undefined) {
dataContexts = [ undefined ];
}
else {
if (dataContext instanceof AEL.Expression) {
let evaluationResult = Template.internalTryEvaluateExpression(dataContext, this._context, true);
if (!evaluationResult.error) {
dataContext = evaluationResult.value;
}
else {
throw new Error(evaluationResult.error);
}
}
if (Array.isArray(dataContext)) {
dataContexts = dataContext;
dataContextIsArray = true;
}
else {
dataContexts = [ dataContext ];
}
}
result = [];
for (let i = 0; i < dataContexts.length; i++) {
if (dataContextIsArray) {
this._context.$index = i;
}
if (dataContexts[i] !== undefined) {
this._context.$data = dataContexts[i];
}
let dropObject = false;
if (when instanceof AEL.Expression) {
let evaluationResult = Template.internalTryEvaluateExpression(when, this._context, false);
let whenValue: boolean = false;
// If $when fails to evaluate or evaluates to anything but a boolean, consider it is false
if (!evaluationResult.error) {
whenValue = typeof evaluationResult.value === "boolean" && evaluationResult.value;
}
if (!evaluationResult.value) {
// Value was not found, and we should warn the client that the Expression was invalid
this.templateExpansionWarnings.push(`WARN: Unable to parse the Adaptive Expression ${when}. The $when condition has been set to false by default.`);
}
dropObject = !whenValue;
} else if (when) {
// If $when was provided, but it is not an AEL.Expression, drop the object
this.templateExpansionWarnings.push(`WARN: ${when} is not an Adaptive Expression. The $when condition has been set to false by default.`);
dropObject = true;
}
if (!dropObject) {
let expandedObject = this.expandSingleObject(node);
if (expandedObject !== null) {
result.push(expandedObject);
}
}
}
if (result.length === 0) {
result = null;
}
else if (result.length === 1) {
result = result[0];
}
}
else {
result = node;
}
this._context.restoreLastState();
return result;
}
/**
* Initializes a new Template instance based on the provided payload.
* Once created, the instance can be bound to different data objects
* in a loop.
*
* @param payload The template payload.
*/
constructor(payload: any) {
this._preparedPayload = Template.prepare(payload);
}
/**
* Expands the template using the provided context. Template expansion involves
* evaluating the expressions used in the original template payload, as well as
* repeating (expanding) parts of that payload that are bound to arrays.
*
* Example:
*
* ```typescript
* let context = {
* $root: {
* firstName: "John",
* lastName: "Doe",
* children: [
* { fullName: "Jane Doe", age: 9 },
* { fullName: "Alex Doe", age: 12 }
* ]
* }
* }
*
* let templatePayload = {
* type: "AdaptiveCard",
* version: "1.2",
* body: [
* {
* type: "TextBlock",
* text: "${firstName} ${lastName}"
* },
* {
* type: "TextBlock",
* $data: "${children}",
* text: "${fullName} (${age})"
* }
* ]
* }
*
* let template = new Template(templatePayload);
*
* let expandedTemplate = template.expand(context);
* ```
*
* With the above code, the value of `expandedTemplate` will be
*
* ```json
* {
* type: "AdaptiveCard",
* version: "1.2",
* body: [
* {
* type: "TextBlock",
* text: "John Doe"
* },
* {
* type: "TextBlock",
* text: "Jane Doe (9)"
* },
* {
* type: "TextBlock",
* text: "Alex Doe (12)"
* }
* ]
* }
* ```
*
* @param context The context to bind the template to.
* @returns A value representing the expanded template. The type of that value
* is dependent on the type of the original template payload passed to the constructor.
*/
expand(context: IEvaluationContext): any {
this.templateExpansionWarnings = [];
this._context = new EvaluationContext(context);
return this.internalExpand(this._preparedPayload);
}
/**
* Getter method for the array of warning strings
* @returns An array storing any warnings that occurred while expanding the template
*/
public getLastTemplateExpansionWarnings(): string[] {
return this.templateExpansionWarnings;
}
}