Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[js-transform] Inline java script support #11473

Merged
merged 3 commits into from
Dec 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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