blob: d71933ee841e85965703eb4188f9a10bc37f7748 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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.
*/
package org.apache.commons.scxml2.env.javascript;
import java.io.IOException;
import java.util.UUID;
import javax.script.Bindings;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
import javax.script.SimpleScriptContext;
import org.apache.commons.io.IOUtils;
import org.apache.commons.scxml2.Context;
import org.apache.commons.scxml2.Evaluator;
import org.apache.commons.scxml2.EvaluatorProvider;
import org.apache.commons.scxml2.SCXMLExpressionException;
import org.apache.commons.scxml2.env.AbstractBaseEvaluator;
import org.apache.commons.scxml2.env.EffectiveContextMap;
import org.apache.commons.scxml2.model.SCXML;
/**
* Embedded JavaScript expression evaluator for SCXML expressions using the JDK 8+ Nashorn Script Engine.
* <p>
* Each JSEvaluator maintains a single {@link ScriptContext} instance to be used for only a single SCXML instance as
* the Nashorn global state is shared through the {@link ScriptContext#ENGINE_SCOPE} binding.
* </p>
* <p>Sharing and reusing JSEvaluator instances for multiple SCXML instances therefore should <em>not</em> be done.</p>
* <p>
* As the JDK Script Engine state is <em>not</em> serializable, and neither are Javascript <code>native</code> Objects,
* the {@link ScriptContext} state is <em>not</em> retained during serialization (transient).
* </p>
* <p>
* SCXML instance (de)serialization using the javascript language therefore only will work reliably as long as no
* Javascript native Objects are used/stored in the context nor (other) modifications are made to the Nashorn global state.
* </p>
*/
public class JSEvaluator extends AbstractBaseEvaluator {
/**
* Unique context variable name used for temporary reference to assign data (thus must be a valid variable name)
*/
private static final String ASSIGN_VARIABLE_NAME = "a"+UUID.randomUUID().toString().replace('-','x');
public static final String SUPPORTED_DATA_MODEL = Evaluator.ECMASCRIPT_DATA_MODEL;
public static class JSEvaluatorProvider implements EvaluatorProvider {
@Override
public String getSupportedDatamodel() {
return SUPPORTED_DATA_MODEL;
}
@Override
public Evaluator getEvaluator() {
return new JSEvaluator();
}
@Override
public Evaluator getEvaluator(final SCXML document) {
return new JSEvaluator();
}
}
private static final String SCXML_SYSTEM_CONTEXT = "_scxmlSystemContext";
/** Error message if evaluation context is not a JexlContext. */
private static final String ERR_CTX_TYPE = "Error evaluating JavaScript "
+ "expression, Context must be a org.apache.commons.scxml2.env.javascript.JSContext";
/** shared singleton Nashorn ScriptEngine **/
private static ScriptEngine engine;
/** Nashorn Global initialization script, loaded from <code>init_global.js</code> classpath resource */
private static String initGlobalsScript;
/** ScriptContext for a single SCXML instance (JSEvaluator also cannot be shared between SCXML instances) */
private transient ScriptContext scriptContext;
/**
* Initialize the singleton Javascript ScriptEngine to be used with a separate ScriptContext for each SCXML instance
* not sharing their global scope, see {@link #getScriptContext(JSContext)}.
* <p>
* The SCXML required protected system variables and (possible) other Javascript global initializations are defined
* in a <code>init_global.js</code> script which is pre-loaded as (classpath) resource, to be executed once during
* initialization of a new Javascript (Nashorn) Global.
* </p>
*/
protected synchronized static void initEngine() {
if (engine == null) {
engine = new ScriptEngineManager().getEngineByName("JavaScript");
try {
initGlobalsScript = IOUtils.toString(JSEvaluator.class.getResourceAsStream("init_global.js"), "UTF-8");
}
catch (IOException ioe) {
throw new RuntimeException("Failed to load init_global.js from classpath", ioe);
}
}
}
/**
* Get the singleton ScriptEngine, initializing it on first access
* @return The ScriptEngine
*/
protected ScriptEngine getEngine() {
if (engine == null) {
initEngine();
}
return engine;
}
/**
* Get the current ScriptContext or create a new one.
* <p>
* The ScriptContext is (to be) shared across invocations for the same SCXML instance as it holds the Javascript 'global'
* context.
* </p>
* <p>
* The ScriptContext is using a {@link ScriptContext#ENGINE_SCOPE} as provided by the engine, which in case of Nashorn
* is bound to the Javscript global context. Note: do <em>not</em> confuse this with the {@link ScriptContext#GLOBAL_SCOPE} binding.
* </p>
* <p>For a newly created ScriptContext (and thus a new Javascript global context), the Javascript global context is
* initialized with the required and protected SCXML system variables and builtin In() operator via the
* <code>init_global.js</code> script, loaded as classpath resource.</p>
* <p>
* The SCXML system variables are bound as <code>"_scxmlSystemContext"</code> variable in the ENGINE_SCOPE
* as needed for the <code>init_global.js</code> script in the global context.
* This variable is bound to the ENGINE_SCOPE to ensure it cannot be 'shadowed' by an overriding variable assignment.
* </p>
* The provided SCXML Context variables are bound via the GLOBAL_SCOPE using a {@link JSBindings} wrapper for each
* invocation.
* </p>
* <p>
* As the GLOBAL_SCOPE SCXML context variables <em>can</em> be overridden, which will result in new 'shadow'
* variables in the ENGINE_SCOPE, as well as new variables can be added to the ENGINE_SCOPE during script evaluation,
* after script execution all ENGINE_SCOPE variables (except the <code>"_scxmlSystemContext"</code> variable) must be
* copied/merged into the SCXML context to synchronize the SCXML context.
* </p>
* @param jsContext The current SCXML context
* @return The SCXML instance shared ScriptContext
* @throws ScriptException Thrown if the initialization of the Global Javascript engine itself failed
*/
protected ScriptContext getScriptContext(JSContext jsContext) throws ScriptException {
if (scriptContext == null) {
scriptContext = new SimpleScriptContext();
scriptContext.setBindings(getEngine().createBindings(), ScriptContext.ENGINE_SCOPE);
scriptContext.setBindings(new JSBindings(jsContext), ScriptContext.GLOBAL_SCOPE);
scriptContext.getBindings(ScriptContext.ENGINE_SCOPE).put(SCXML_SYSTEM_CONTEXT, jsContext.getSystemContext().getVars());
getEngine().eval(initGlobalsScript, scriptContext);
}
else {
// ensure updated / replaced SystemContext is used (like after SCXML instance go/reset)
scriptContext.getBindings(ScriptContext.ENGINE_SCOPE).put(SCXML_SYSTEM_CONTEXT, jsContext.getSystemContext().getVars());
((JSBindings)scriptContext.getBindings(ScriptContext.GLOBAL_SCOPE)).setContext(jsContext);
}
return scriptContext;
}
@Override
public String getSupportedDatamodel() {
return SUPPORTED_DATA_MODEL;
}
/**
* Javascript engine semantics, using a retained global state, requires global SCXML context execution
* @return true
*/
@Override
public boolean requiresGlobalContext() {
return true;
}
/**
* Creates a child context.
*
* @return Returns a new child JSContext.
*
*/
@Override
public Context newContext(Context parent) {
return new JSContext(parent);
}
/**
* Evaluates a Javascript expression using an SCXML instance shared {@link #getScriptContext(JSContext)}.
* <p>
* After evaluation all the resulting Javascript Global context (in {@link ScriptContext#ENGINE_SCOPE} are first
* copied/merged back into the SCXML context, before the evaluation result (if any) is returned.
* </p>
* @param context SCXML context.
* @param expression Expression to evaluate.
* @return Result of expression evaluation or <code>null</code>.
* @throws SCXMLExpressionException Thrown if the expression was invalid or the execution raised an error itself.
*/
@Override
public Object eval(Context context, String expression) throws SCXMLExpressionException {
if (expression == null) {
return null;
}
if (!(context instanceof JSContext)) {
throw new SCXMLExpressionException(ERR_CTX_TYPE);
}
try {
JSContext effectiveContext = getEffectiveContext((JSContext)context);
ScriptContext scriptContext = getScriptContext(effectiveContext);
Object ret = getEngine().eval(expression, scriptContext);
// copy Javascript global variables to SCXML context.
copyJavascriptGlobalsToScxmlContext(scriptContext.getBindings(ScriptContext.ENGINE_SCOPE), effectiveContext);
return ret;
} catch (Exception x) {
throw new SCXMLExpressionException("Error evaluating ['" + expression + "'] " + x);
}
}
/**
* Evaluates a conditional expression using the <code>eval()</code> method and
* casting the result to a Boolean.
*
* @param context SCXML context.
* @param expression Expression to evaluate.
*
* @return Boolean casted result.
*
* @throws SCXMLExpressionException Thrown if the expression was invalid.
*/
@Override
public Boolean evalCond(Context context, String expression) throws SCXMLExpressionException {
return (Boolean)eval(context, "Boolean("+expression+")");
}
/**
* @see Evaluator#evalAssign(Context, String, Object, AssignType, String)
*/
public void evalAssign(final Context ctx, final String location, final Object data, final AssignType type,
final String attr) throws SCXMLExpressionException {
StringBuilder sb = new StringBuilder(location).append("=").append(ASSIGN_VARIABLE_NAME);
try {
ctx.getVars().put(ASSIGN_VARIABLE_NAME, data);
eval(ctx, sb.toString());
} finally {
ctx.getVars().remove(ASSIGN_VARIABLE_NAME);
}
}
/**
* Executes the Javascript script using the <code>eval()</code> method
*
* @param ctx SCXML context.
* @param script Script to execute.
*
* @return Result of script execution or <code>null</code>.
*
* @throws SCXMLExpressionException Thrown if the script was invalid.
*/
@Override
public Object evalScript(Context ctx, String script) throws SCXMLExpressionException {
return eval(ctx, script);
}
/**
* Create a new context which is the summation of contexts from the
* current state to document root, child has priority over parent
* in scoping rules.
*
* @param nodeCtx The JSContext for this state.
* @return The effective JSContext for the path leading up to
* document root.
*/
protected JSContext getEffectiveContext(final JSContext nodeCtx) {
return new JSContext(nodeCtx, new EffectiveContextMap(nodeCtx));
}
/**
* Copy the Javscript global context (i.e. nashorn Global instance) variables to SCXML {@code jsContext}
* in order to make sure all the new global variables set by the JavaScript engine after evaluation are
* available from {@link JSContext} instance as well.
* <p>Note: the internal <code>"_scxmlSystemContext</code> variable is always skipped.</p>
* @param global The Javascript Bindings holding the Javascript Global context variables
* @param jsContext The SCXML context to copy/merge the variables into
*/
private void copyJavascriptGlobalsToScxmlContext(final Bindings global, final JSContext jsContext) {
if (global != null) {
for (String key : global.keySet()) {
if (!SCXML_SYSTEM_CONTEXT.equals(key)) {
jsContext.set(key, global.get(key));
}
}
}
}
}