Skip to content

Commit

Permalink
[js-transform] inline java script support (openhab#11473)
Browse files Browse the repository at this point in the history
* [js-transform] inline java script support

Signed-off-by: Pauli Anttila <pauli.anttila@gmail.com>
  • Loading branch information
paulianttila authored Dec 12, 2021
1 parent 0bd0d87 commit 604d5b6
Show file tree
Hide file tree
Showing 5 changed files with 105 additions and 28 deletions.
8 changes: 8 additions & 0 deletions bundles/org.openhab.transform.javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
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;

Expand All @@ -27,6 +32,7 @@
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;
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -78,6 +86,35 @@ protected CompiledScript getScript(final String filename) throws TransformationE
}
}

/**
* 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.
*
Expand All @@ -87,4 +124,10 @@ protected void removeFromCache(String fileName) {
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,47 +71,55 @@ public JavaScriptTransformationService(final @Reference JavaScriptEngineManager
}

/**
* 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;
String result = "";

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");
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");
}
}
}

String result = "";
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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ private void copyDirectory(String from, String to) throws IOException {
});
}

@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";
Expand Down

0 comments on commit 604d5b6

Please sign in to comment.