From f49a3a3f8bfeed81fedc9731022d1e8d6ff41b9a Mon Sep 17 00:00:00 2001 From: pali Date: Sat, 31 Jul 2021 13:42:50 +0300 Subject: [PATCH] [js-transform] Introduced support for additional parameters (#10901) * [js-transform] Introduced support for additional parameters Signed-off-by: Pauli Anttila * Added junit tests and updated readme Signed-off-by: Pauli Anttila * Typo fixes Signed-off-by: Pauli Anttila * Typo fix Signed-off-by: Pauli Anttila * Fixed junit test Signed-off-by: Pauli Anttila --- .../README.md | 22 ++- .../conf/transform/js/readme/readme.js | 4 + .../conf/transform/readme.js | 4 + .../conf/transform/returntest.js | 3 + .../conf/transform/scale.js | 3 + .../conf/transform/sum.js | 3 + .../JavaScriptTransformationService.java | 67 +++++++- .../JavaScriptTransformationServiceTest.java | 152 ++++++++++++++++++ 8 files changed, 249 insertions(+), 9 deletions(-) create mode 100644 bundles/org.openhab.transform.javascript/conf/transform/js/readme/readme.js create mode 100644 bundles/org.openhab.transform.javascript/conf/transform/readme.js create mode 100644 bundles/org.openhab.transform.javascript/conf/transform/returntest.js create mode 100644 bundles/org.openhab.transform.javascript/conf/transform/scale.js create mode 100644 bundles/org.openhab.transform.javascript/conf/transform/sum.js create mode 100644 bundles/org.openhab.transform.javascript/src/test/java/org/openhab/transform/javascript/internal/JavaScriptTransformationServiceTest.java diff --git a/bundles/org.openhab.transform.javascript/README.md b/bundles/org.openhab.transform.javascript/README.md index bd6a09018fc9c..a0d7d0ce84726 100644 --- a/bundles/org.openhab.transform.javascript/README.md +++ b/bundles/org.openhab.transform.javascript/README.md @@ -5,7 +5,7 @@ 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. -## Example +## 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`). @@ -18,6 +18,26 @@ transform/getValue.js: })(input) ``` +JavaScript transformation syntax also support additional parameters which can be passed to the script. +This can prevent redundancy when transformation is needed for several use cases, but with small adaptations. +additional parameters can be passed to the script via [URI](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier) query syntax. + +As `input` name is reserved for transformed data, it can't be used in query parameters. +Also `?` and `&` characters are reserved, but if they need to passed as additional data, they can be escaped according to URI syntax. + + +transform/scale.js: +``` +(function(data, cf, d) { + return parseFloat(data) * parseFloat(cf) / parseFloat(d); +})(input, correctionFactor, divider) +``` + +`transform/scale.js?correctionFactor=1.1÷r=10` + +Following example will return value `23.54` when `input` data is `214`. + + ## Test JavaScript You can use online JavaScript testers to validate your script. diff --git a/bundles/org.openhab.transform.javascript/conf/transform/js/readme/readme.js b/bundles/org.openhab.transform.javascript/conf/transform/js/readme/readme.js new file mode 100644 index 0000000000000..28537e5c83c1f --- /dev/null +++ b/bundles/org.openhab.transform.javascript/conf/transform/js/readme/readme.js @@ -0,0 +1,4 @@ +(function(i) { + var array = i.split(" "); + return array[array.length - 1].length; +})(input) \ No newline at end of file diff --git a/bundles/org.openhab.transform.javascript/conf/transform/readme.js b/bundles/org.openhab.transform.javascript/conf/transform/readme.js new file mode 100644 index 0000000000000..28537e5c83c1f --- /dev/null +++ b/bundles/org.openhab.transform.javascript/conf/transform/readme.js @@ -0,0 +1,4 @@ +(function(i) { + var array = i.split(" "); + return array[array.length - 1].length; +})(input) \ No newline at end of file diff --git a/bundles/org.openhab.transform.javascript/conf/transform/returntest.js b/bundles/org.openhab.transform.javascript/conf/transform/returntest.js new file mode 100644 index 0000000000000..874477a77a0dd --- /dev/null +++ b/bundles/org.openhab.transform.javascript/conf/transform/returntest.js @@ -0,0 +1,3 @@ +(function(i, a, b) { + return b; +})(input, a, test) \ No newline at end of file diff --git a/bundles/org.openhab.transform.javascript/conf/transform/scale.js b/bundles/org.openhab.transform.javascript/conf/transform/scale.js new file mode 100644 index 0000000000000..329ef5102f900 --- /dev/null +++ b/bundles/org.openhab.transform.javascript/conf/transform/scale.js @@ -0,0 +1,3 @@ +(function(data, cf, d) { + return parseFloat(data) * parseFloat(cf) / parseFloat(d); +})(input, correctionFactor, divider) diff --git a/bundles/org.openhab.transform.javascript/conf/transform/sum.js b/bundles/org.openhab.transform.javascript/conf/transform/sum.js new file mode 100644 index 0000000000000..2bf0e79e04dd5 --- /dev/null +++ b/bundles/org.openhab.transform.javascript/conf/transform/sum.js @@ -0,0 +1,3 @@ +(function(i, a, b) { + return parseInt(i) + parseInt(a) + parseInt(b); +})(input, a, b) \ No newline at end of file diff --git a/bundles/org.openhab.transform.javascript/src/main/java/org/openhab/transform/javascript/internal/JavaScriptTransformationService.java b/bundles/org.openhab.transform.javascript/src/main/java/org/openhab/transform/javascript/internal/JavaScriptTransformationService.java index 660bd632efd7c..7a712568c2449 100644 --- a/bundles/org.openhab.transform.javascript/src/main/java/org/openhab/transform/javascript/internal/JavaScriptTransformationService.java +++ b/bundles/org.openhab.transform.javascript/src/main/java/org/openhab/transform/javascript/internal/JavaScriptTransformationService.java @@ -14,11 +14,16 @@ import java.io.File; import java.io.FilenameFilter; +import java.io.UnsupportedEncodingException; import java.net.URI; +import java.net.URLDecoder; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.stream.Collectors; import javax.script.Bindings; @@ -56,6 +61,8 @@ public class JavaScriptTransformationService implements TransformationService, C private static final String CONFIG_PARAM_FUNCTION = "function"; private static final String[] FILE_NAME_EXTENSIONS = { "js" }; + private static final String SCRIPT_DATA_WORD = "input"; + private final JavaScriptEngineManager manager; @Activate @@ -70,25 +77,44 @@ public JavaScriptTransformationService(final @Reference JavaScriptEngineManager * transformations one should use subfolders. * * @param filename the name of the file which contains the Java script - * transformation rule. Transformation service inject input - * (source) to 'input' variable. + * 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. * @param source the input to transform */ @Override public @Nullable String transform(String filename, String source) throws TransformationException { - if (filename == null || source == null) { - throw new TransformationException("the given parameters 'filename' and 'source' must not be null"); - } - final long startTime = System.currentTimeMillis(); logger.debug("about to transform '{}' by the JavaScript '{}'", source, filename); + Map 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 = ""; try { - final CompiledScript cScript = manager.getScript(filename); + final CompiledScript cScript = manager.getScript(fn); final Bindings bindings = cScript.getEngine().createBindings(); - bindings.put("input", source); + bindings.put(SCRIPT_DATA_WORD, source); + vars.forEach((k, v) -> bindings.put(k, v)); result = String.valueOf(cScript.eval(bindings)); return result; } catch (ScriptException e) { @@ -99,6 +125,31 @@ public JavaScriptTransformationService(final @Reference JavaScriptEngineManager } } + private boolean isReservedWordUsed(Map map) { + for (String key : map.keySet()) { + if (SCRIPT_DATA_WORD.equals(key)) { + return true; + } + } + return false; + } + + private Map splitQuery(@Nullable String query) throws UnsupportedEncodingException { + Map result = new LinkedHashMap<>(); + if (query != null) { + String[] pairs = query.split("&"); + for (String pair : pairs) { + String[] keyval = pair.split("="); + if (keyval.length != 2) { + throw new UnsupportedEncodingException(); + } else { + result.put(URLDecoder.decode(keyval[0], "UTF-8"), URLDecoder.decode(keyval[1], "UTF-8")); + } + } + } + return result; + } + @Override public @Nullable Collection getParameterOptions(URI uri, String param, @Nullable String context, @Nullable Locale locale) { diff --git a/bundles/org.openhab.transform.javascript/src/test/java/org/openhab/transform/javascript/internal/JavaScriptTransformationServiceTest.java b/bundles/org.openhab.transform.javascript/src/test/java/org/openhab/transform/javascript/internal/JavaScriptTransformationServiceTest.java new file mode 100644 index 0000000000000..f379b7e6c4dd2 --- /dev/null +++ b/bundles/org.openhab.transform.javascript/src/test/java/org/openhab/transform/javascript/internal/JavaScriptTransformationServiceTest.java @@ -0,0 +1,152 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.transform.javascript.internal; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.openhab.core.transform.TransformationException; +import org.osgi.framework.BundleContext; + +/** + * @author Pauli Anttila - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.WARN) +public class JavaScriptTransformationServiceTest { + + private static final String BASE_FOLDER = "target"; + private static final String SRC_FOLDER = "conf"; + private static final String CONFIG_FOLDER = BASE_FOLDER + File.separator + SRC_FOLDER; + + private @Mock BundleContext bundleContext; + + private TestableJavaScriptTransformationService processor; + + private class TestableJavaScriptTransformationService extends JavaScriptTransformationService { + public TestableJavaScriptTransformationService(JavaScriptEngineManager manager) { + super(manager); + } + }; + + @BeforeEach + public void setUp() throws IOException { + JavaScriptEngineManager manager = new JavaScriptEngineManager(); + processor = new TestableJavaScriptTransformationService(manager); + copyDirectory(SRC_FOLDER, CONFIG_FOLDER); + } + + @AfterEach + public void tearDown() throws IOException { + try (Stream walk = Files.walk(Path.of(CONFIG_FOLDER))) { + walk.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete); + } + } + + private void copyDirectory(String from, String to) throws IOException { + Files.walk(Paths.get(from)).forEach(fromPath -> { + Path toPath = Paths.get(to, fromPath.toString().substring(from.length())); + try { + Files.copy(fromPath, toPath); + } catch (IOException e) { + } + }); + } + + @Test + public void testReadmeExampleWithoutSubFolder() throws Exception { + final String DATA = "foo bar baz"; + final String SCRIPT = "readme.js"; + + String transformedResponse = processor.transform(SCRIPT, DATA); + assertEquals("3", transformedResponse); + } + + @Test + public void testReadmeExampleWithSubFolders() throws Exception { + final String DATA = "foo bar baz"; + final String SCRIPT = "js/readme/readme.js"; + + String transformedResponse = processor.transform(SCRIPT, DATA); + assertEquals("3", transformedResponse); + } + + @Test + public void testReadmeScaleExample() throws Exception { + final String DATA = "214"; + final String SCRIPT = "scale.js?correctionFactor=1.1÷r=10.js"; + + String transformedResponse = processor.transform(SCRIPT, DATA); + assertEquals("23.54", transformedResponse); + } + + @Test + public void testAdditionalVariables() throws Exception { + final String DATA = "100"; + final String SCRIPT = "sum.js?a=10&b=1"; + + String transformedResponse = processor.transform(SCRIPT, DATA); + assertEquals("111", transformedResponse); + } + + @Test + public void testIllegalVariableName() throws Exception { + final String DATA = "100"; + final String SCRIPT = "sum.js?a=10&input=fail&b=1"; + + Exception exception = assertThrows(TransformationException.class, () -> processor.transform(SCRIPT, DATA)); + assertEquals("'input' word is reserved and can't be used in additional parameters", exception.getMessage()); + } + + @Test + public void testIllegalQuestionmarkSequence() throws Exception { + final String DATA = "100"; + final String SCRIPT = "sum.js?a=1&test=ab?d&b=2"; + + Exception exception = assertThrows(TransformationException.class, () -> processor.transform(SCRIPT, DATA)); + assertEquals("Questionmark should be defined only once in the filename", exception.getMessage()); + } + + @Test + public void testIllegalAmbersandSequence() throws Exception { + final String DATA = "foo"; + final String SCRIPT = "returntest.js?a=1&test=ab&d&b=2"; + + Exception exception = assertThrows(TransformationException.class, () -> processor.transform(SCRIPT, DATA)); + assertEquals("Illegal filename syntax", exception.getMessage()); + } + + @Test + public void testEncodedSpecialCharacters() throws Exception { + final String DATA = "100"; + final String SCRIPT = "returntest.js?a=1&test=ab%3Fd%26f&b=2"; + + String transformedResponse = processor.transform(SCRIPT, DATA); + assertEquals("ab?d&f", transformedResponse); + } +}