[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:
parent
26729956bc
commit
45729890b1
@ -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.
|
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.
|
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
|
## 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`).
|
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`.
|
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
|
## Test JavaScript
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,11 @@ import java.io.FileInputStream;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.io.Reader;
|
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.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
@ -27,6 +32,7 @@ import javax.script.ScriptEngineManager;
|
|||||||
import javax.script.ScriptException;
|
import javax.script.ScriptException;
|
||||||
|
|
||||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.openhab.core.cache.ExpiringCacheMap;
|
||||||
import org.openhab.core.transform.TransformationException;
|
import org.openhab.core.transform.TransformationException;
|
||||||
import org.osgi.service.component.annotations.Component;
|
import org.osgi.service.component.annotations.Component;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@ -46,6 +52,8 @@ public class JavaScriptEngineManager {
|
|||||||
private final ScriptEngineManager manager = new ScriptEngineManager();
|
private final ScriptEngineManager manager = new ScriptEngineManager();
|
||||||
/* keep memory foot print low. max 2 concurrent threads are estimated */
|
/* 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 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
|
* 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}
|
* @return a pre compiled script {@link CompiledScript}
|
||||||
* @throws TransformationException if compile of JavaScript failed
|
* @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) {
|
synchronized (compiledScriptMap) {
|
||||||
CompiledScript compiledScript = compiledScriptMap.get(filename);
|
CompiledScript compiledScript = compiledScriptMap.get(filename);
|
||||||
if (compiledScript != null) {
|
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.
|
* remove a pre compiled script from cache.
|
||||||
*
|
*
|
||||||
@ -87,4 +124,10 @@ public class JavaScriptEngineManager {
|
|||||||
logger.debug("Removing JavaScript {} from cache.", fileName);
|
logger.debug("Removing JavaScript {} from cache.", fileName);
|
||||||
compiledScriptMap.remove(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
* transformation rule to be read from a file which is stored under the
|
||||||
* 'configurations/transform' folder. To organize the various
|
* 'configurations/transform' folder. To organize the various
|
||||||
* transformations one should use subfolders.
|
* 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
|
* transformation rule. Filename can also include additional
|
||||||
* variables in URI query variable format which will be injected
|
* variables in URI query variable format which will be injected
|
||||||
* to script engine. Transformation service inject input (source)
|
* to script engine. 2) inline script when starting with '|' character.
|
||||||
* to 'input' variable.
|
* Transformation service inject input (source) to 'input' variable.
|
||||||
* @param source the input to transform
|
* @param source the input to transform
|
||||||
*/
|
*/
|
||||||
@Override
|
@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();
|
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();
|
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 = "";
|
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 {
|
try {
|
||||||
final CompiledScript cScript = manager.getScript(fn);
|
|
||||||
final Bindings bindings = cScript.getEngine().createBindings();
|
final Bindings bindings = cScript.getEngine().createBindings();
|
||||||
bindings.put(SCRIPT_DATA_WORD, source);
|
bindings.put(SCRIPT_DATA_WORD, source);
|
||||||
vars.forEach((k, v) -> bindings.put(k, v));
|
vars.forEach((k, v) -> bindings.put(k, v));
|
||||||
|
|||||||
@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
<config-description uri="profile:transform:JS">
|
<config-description uri="profile:transform:JS">
|
||||||
<parameter name="function" type="text" required="true">
|
<parameter name="function" type="text" required="true">
|
||||||
<label>JavaScript Filename</label>
|
<label>JavaScript Filename or Inline Script</label>
|
||||||
<description>Filename of the JavaScript in the transform folder. The state will be available in the variable
|
<description>Filename of the JavaScript in the transform folder or inline script starting with "|" character. The
|
||||||
\"input\".</description>
|
state will be available in the variable "input".</description>
|
||||||
<limitToOptions>false</limitToOptions>
|
<limitToOptions>false</limitToOptions>
|
||||||
</parameter>
|
</parameter>
|
||||||
<parameter name="sourceFormat" type="text">
|
<parameter name="sourceFormat" type="text">
|
||||||
|
|||||||
@ -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
|
@Test
|
||||||
public void testReadmeExampleWithoutSubFolder() throws Exception {
|
public void testReadmeExampleWithoutSubFolder() throws Exception {
|
||||||
final String DATA = "foo bar baz";
|
final String DATA = "foo bar baz";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user