[js-transform] inline java script support (#11473)

* [js-transform] inline java script support

Signed-off-by: Pauli Anttila <pauli.anttila@gmail.com>
This commit is contained in:
pali 2021-12-12 23:09:36 +02:00 committed by GitHub
parent 26729956bc
commit 45729890b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 107 additions and 30 deletions

View File

@ -5,6 +5,10 @@ Transform an input to an output using JavaScript.
It expects the transformation rule to be read from a file which is stored under the `transform` folder.
To organize the various transformations, one should use subfolders.
Simple transformation rules can also be given as a inline script.
Inline script should be start by `|` character following the JavaScript.
Beware that complex inline script could cause issues to e.g. item file parsing.
## Examples
Let's assume we have received a string containing `foo bar baz` and we're looking for a length of the last word (`baz`).
@ -37,6 +41,10 @@ transform/scale.js:
Following example will return value `23.54` when `input` data is `214`.
### Inline script example:
Normally JavaScript transformation is given by filename, e.g. `JS(transform/getValue.js)`.
Inline script can be given by `|` character following the JavaScript, e.g. `JS(| input / 10)`.
## Test JavaScript

View File

@ -17,6 +17,11 @@ import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@ -27,6 +32,7 @@ import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.cache.ExpiringCacheMap;
import org.openhab.core.transform.TransformationException;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
@ -46,6 +52,8 @@ public class JavaScriptEngineManager {
private final ScriptEngineManager manager = new ScriptEngineManager();
/* keep memory foot print low. max 2 concurrent threads are estimated */
private final Map<String, CompiledScript> compiledScriptMap = new ConcurrentHashMap<>(4, 0.5f, 2);
private final ExpiringCacheMap<String, CompiledScript> cacheForInlineScripts = new ExpiringCacheMap<>(
Duration.ofDays(1));
/**
* Get a pre compiled script {@link CompiledScript} from cache. If it is not in the cache, then load it from
@ -55,7 +63,7 @@ public class JavaScriptEngineManager {
* @return a pre compiled script {@link CompiledScript}
* @throws TransformationException if compile of JavaScript failed
*/
protected CompiledScript getScript(final String filename) throws TransformationException {
protected CompiledScript getCompiledScriptByFilename(final String filename) throws TransformationException {
synchronized (compiledScriptMap) {
CompiledScript compiledScript = compiledScriptMap.get(filename);
if (compiledScript != null) {
@ -78,6 +86,35 @@ public class JavaScriptEngineManager {
}
}
/**
* Get a pre compiled script {@link CompiledScript} from cache. If it is not in the cache, then compile
* it and put a pre compiled version into the cache.
*
* @param script JavaScript which should be returned as a pre compiled
* @return a pre compiled script {@link CompiledScript}
* @throws TransformationException if compile of JavaScript failed
*/
protected CompiledScript getCompiledScriptByInlineScript(final String script) throws TransformationException {
synchronized (cacheForInlineScripts) {
try {
final String hash = calcHash(script);
final CompiledScript compiledScript = cacheForInlineScripts.get(hash);
if (compiledScript != null) {
logger.debug("Loading JavaScript from cache.");
return compiledScript;
} else {
logger.debug("Compiling script {}", script);
final ScriptEngine engine = manager.getEngineByName("javascript");
final CompiledScript cScript = ((Compilable) engine).compile(script);
cacheForInlineScripts.put(hash, () -> cScript);
return cScript;
}
} catch (ScriptException | NoSuchAlgorithmException e) {
throw new TransformationException("An error occurred while compiling JavaScript. " + e.getMessage(), e);
}
}
}
/**
* remove a pre compiled script from cache.
*
@ -87,4 +124,10 @@ public class JavaScriptEngineManager {
logger.debug("Removing JavaScript {} from cache.", fileName);
compiledScriptMap.remove(fileName);
}
private String calcHash(final String script) throws NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(script.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
}
}

View File

@ -71,47 +71,55 @@ public class JavaScriptTransformationService implements TransformationService, C
}
/**
* Transforms the input <code>source</code> by Java Script. It expects the
* Transforms the input <code>source</code> by Java Script. If script is a filename, it expects the
* transformation rule to be read from a file which is stored under the
* 'configurations/transform' folder. To organize the various
* transformations one should use subfolders.
*
* @param filename the name of the file which contains the Java script
* @param filenameOrInlineScript parameter can be 1) the name of the file which contains the Java script
* transformation rule. Filename can also include additional
* variables in URI query variable format which will be injected
* to script engine. Transformation service inject input (source)
* to 'input' variable.
* to script engine. 2) inline script when starting with '|' character.
* Transformation service inject input (source) to 'input' variable.
* @param source the input to transform
*/
@Override
public @Nullable String transform(String filename, String source) throws TransformationException {
public @Nullable String transform(String filenameOrInlineScript, String source) throws TransformationException {
final long startTime = System.currentTimeMillis();
logger.debug("about to transform '{}' by the JavaScript '{}'", source, filename);
logger.debug("about to transform '{}' by the JavaScript '{}'", source, filenameOrInlineScript);
Map<String, String> vars = Collections.emptyMap();
String fn = filename;
if (filename.contains("?")) {
String[] parts = filename.split("\\?");
if (parts.length > 2) {
throw new TransformationException("Questionmark should be defined only once in the filename");
}
fn = parts[0];
try {
vars = splitQuery(parts[1]);
} catch (UnsupportedEncodingException e) {
throw new TransformationException("Illegal filename syntax");
}
if (isReservedWordUsed(vars)) {
throw new TransformationException(
"'" + SCRIPT_DATA_WORD + "' word is reserved and can't be used in additional parameters");
}
}
String result = "";
CompiledScript cScript;
if (filenameOrInlineScript.startsWith("|")) {
// inline java script
cScript = manager.getCompiledScriptByInlineScript(filenameOrInlineScript.substring(1));
} else {
String filename = filenameOrInlineScript;
if (filename.contains("?")) {
String[] parts = filename.split("\\?");
if (parts.length > 2) {
throw new TransformationException("Questionmark should be defined only once in the filename");
}
filename = parts[0];
try {
vars = splitQuery(parts[1]);
} catch (UnsupportedEncodingException e) {
throw new TransformationException("Illegal filename syntax");
}
if (isReservedWordUsed(vars)) {
throw new TransformationException(
"'" + SCRIPT_DATA_WORD + "' word is reserved and can't be used in additional parameters");
}
}
cScript = manager.getCompiledScriptByFilename(filename);
}
try {
final CompiledScript cScript = manager.getScript(fn);
final Bindings bindings = cScript.getEngine().createBindings();
bindings.put(SCRIPT_DATA_WORD, source);
vars.forEach((k, v) -> bindings.put(k, v));

View File

@ -6,9 +6,9 @@
<config-description uri="profile:transform:JS">
<parameter name="function" type="text" required="true">
<label>JavaScript Filename</label>
<description>Filename of the JavaScript in the transform folder. The state will be available in the variable
\"input\".</description>
<label>JavaScript Filename or Inline Script</label>
<description>Filename of the JavaScript in the transform folder or inline script starting with "|" character. The
state will be available in the variable "input".</description>
<limitToOptions>false</limitToOptions>
</parameter>
<parameter name="sourceFormat" type="text">

View File

@ -78,6 +78,24 @@ public class JavaScriptTransformationServiceTest {
});
}
@Test
public void testInlineScript() throws Exception {
final String DATA = "100";
final String SCRIPT = "| input / 10";
String transformedResponse = processor.transform(SCRIPT, DATA);
assertEquals("10.0", transformedResponse);
}
@Test
public void testInlineScriptIncludingPipe() throws Exception {
final String DATA = "1";
final String SCRIPT = "| false || (input == '1')";
String transformedResponse = processor.transform(SCRIPT, DATA);
assertEquals("true", transformedResponse);
}
@Test
public void testReadmeExampleWithoutSubFolder() throws Exception {
final String DATA = "foo bar baz";