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`).
})(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.
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;
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
* 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<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 = "";
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) {
}
}
+ private boolean isReservedWordUsed(Map<String, String> map) {
+ for (String key : map.keySet()) {
+ if (SCRIPT_DATA_WORD.equals(key)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private Map<String, String> splitQuery(@Nullable String query) throws UnsupportedEncodingException {
+ Map<String, String> 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<ParameterOption> getParameterOptions(URI uri, String param, @Nullable String context,
@Nullable Locale locale) {
--- /dev/null
+/**
+ * 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<Path> 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);
+ }
+}