From 559d6ad12d88825fd34a1889855d01f3f6ff82b3 Mon Sep 17 00:00:00 2001 From: "Jan N. Klug" Date: Wed, 30 Mar 2022 09:48:10 +0200 Subject: [PATCH 1/3] Add a generic script transformation Signed-off-by: Jan N. Klug --- .../pom.xml | 5 + .../script/ScriptTransformationService.java | 147 ++++++++++++++++++ .../ScriptTransformationServiceTest.java | 141 +++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptTransformationService.java create mode 100644 bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/ScriptTransformationServiceTest.java diff --git a/bundles/org.openhab.core.automation.module.script/pom.xml b/bundles/org.openhab.core.automation.module.script/pom.xml index 1bdd6b83181..98314fe0421 100644 --- a/bundles/org.openhab.core.automation.module.script/pom.xml +++ b/bundles/org.openhab.core.automation.module.script/pom.xml @@ -20,6 +20,11 @@ org.openhab.core.automation ${project.version} + + org.openhab.core.bundles + org.openhab.core.transform + ${project.version} + diff --git a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptTransformationService.java b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptTransformationService.java new file mode 100644 index 00000000000..9c575b3b9a3 --- /dev/null +++ b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptTransformationService.java @@ -0,0 +1,147 @@ +/** + * Copyright (c) 2010-2022 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.core.automation.module.script; + +import java.util.HashMap; +import java.util.Map; + +import javax.script.Compilable; +import javax.script.CompiledScript; +import javax.script.ScriptContext; +import javax.script.ScriptEngine; +import javax.script.ScriptException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.common.registry.RegistryChangeListener; +import org.openhab.core.transform.TransformationConfiguration; +import org.openhab.core.transform.TransformationConfigurationRegistry; +import org.openhab.core.transform.TransformationException; +import org.openhab.core.transform.TransformationService; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link ScriptTransformationService} implements a {@link TransformationService} using any available script + * language + * + * @author Jan N. Klug - Initial contribution + */ +@Component(service = TransformationService.class, property = { "openhab.transform=SCRIPT" }) +@NonNullByDefault +public class ScriptTransformationService + implements TransformationService, RegistryChangeListener { + public static final String OPENHAB_TRANSFORMATION_SCRIPT = "openhab-transformation-script-"; + + private final Map scriptEngineContainers = new HashMap<>(); + private final Map compiledScripts = new HashMap<>(); + private final Map scriptCache = new HashMap<>(); + + private final TransformationConfigurationRegistry transformationConfigurationRegistry; + private final ScriptEngineManager scriptEngineManager; + + @Activate + public ScriptTransformationService( + @Reference TransformationConfigurationRegistry transformationConfigurationRegistry, + @Reference ScriptEngineManager scriptEngineManager) { + this.transformationConfigurationRegistry = transformationConfigurationRegistry; + this.scriptEngineManager = scriptEngineManager; + } + + @Override + public @Nullable String transform(String function, String source) throws TransformationException { + int splitPoint = function.indexOf(":"); + if (splitPoint < 1) { + throw new TransformationException("Script Type must be prepended to transformation UID."); + } + String scriptType = function.substring(0, splitPoint); + String scriptUid = function.substring(splitPoint + 1); + + String script = scriptCache.get(scriptUid); + if (script == null) { + TransformationConfiguration transformationConfiguration = transformationConfigurationRegistry + .get(scriptUid); + if (transformationConfiguration != null) { + script = transformationConfiguration.getContent(); + } + if (script == null) { + throw new TransformationException("Could not get script for UID '" + scriptUid + "'."); + } + + scriptCache.put(scriptUid, script); + } + + if (!scriptEngineManager.isSupported(scriptType)) { + // language has been removed, clear container and compiled scripts if found + if (scriptEngineContainers.containsKey(scriptUid)) { + scriptEngineManager.removeEngine(OPENHAB_TRANSFORMATION_SCRIPT + scriptUid); + } + clearCache(scriptUid); + throw new TransformationException( + "Script type '" + scriptType + "' is not supported by any available script engine."); + } + + ScriptEngineContainer scriptEngineContainer = scriptEngineContainers.computeIfAbsent(scriptUid, + k -> scriptEngineManager.createScriptEngine(scriptType, OPENHAB_TRANSFORMATION_SCRIPT + k)); + + if (scriptEngineContainer == null) { + throw new TransformationException("Failed to create script engine container for '" + function + "'."); + } + if (scriptEngineContainer != null) { + try { + CompiledScript compiledScript = this.compiledScripts.get(scriptUid); + + if (compiledScript == null && scriptEngineContainer.getScriptEngine() instanceof Compilable) { + // no compiled script available but compiling is supported + compiledScript = ((Compilable) scriptEngineContainer.getScriptEngine()).compile(script); + this.compiledScripts.put(scriptUid, compiledScript); + } + + ScriptEngine engine = compiledScript != null ? compiledScript.getEngine() + : scriptEngineContainer.getScriptEngine(); + ScriptContext executionContext = engine.getContext(); + executionContext.setAttribute("inputString", source, ScriptContext.ENGINE_SCOPE); + + Object result = compiledScript != null ? compiledScript.eval() : engine.eval(script); + return result == null ? null : result.toString(); + } catch (ScriptException e) { + throw new TransformationException("Failed to execute script.", e); + } + } + + return null; + } + + @Override + public void added(TransformationConfiguration element) { + clearCache(element.getUID()); + } + + @Override + public void removed(TransformationConfiguration element) { + + clearCache(element.getUID()); + } + + @Override + public void updated(TransformationConfiguration oldElement, TransformationConfiguration element) { + clearCache(element.getUID()); + } + + private void clearCache(String uid) { + compiledScripts.remove(uid); + scriptEngineContainers.remove(uid); + scriptCache.remove(uid); + } +} diff --git a/bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/ScriptTransformationServiceTest.java b/bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/ScriptTransformationServiceTest.java new file mode 100644 index 00000000000..865655e0407 --- /dev/null +++ b/bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/ScriptTransformationServiceTest.java @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2010-2022 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.core.automation.module.script; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import javax.script.ScriptContext; +import javax.script.ScriptEngine; +import javax.script.ScriptException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +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.TransformationConfiguration; +import org.openhab.core.transform.TransformationConfigurationRegistry; +import org.openhab.core.transform.TransformationException; + +/** + * The {@link ScriptTransformationServiceTest} holds tests for the {@link ScriptTransformationService} + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.WARN) +public class ScriptTransformationServiceTest { + private static final String COMPILABLE_SCRIPT_TYPE = "compilableScript"; + private static final String SCRIPT_TYPE = "script"; + private static final String SCRIPT_UID = "scriptUid"; + private static final String SCRIPT = "script"; + private static final String SCRIPT_OUTPUT = "output"; + + private static final TransformationConfiguration TRANSFORMATION_CONFIGURATION = new TransformationConfiguration( + SCRIPT_UID, "label", "script", null, SCRIPT); + private @Mock @NonNullByDefault({}) TransformationConfigurationRegistry transformationConfigurationRegistry; + private @Mock @NonNullByDefault({}) ScriptEngineManager scriptEngineManager; + private @Mock @NonNullByDefault({}) ScriptEngineContainer scriptEngineContainer; + private @Mock @NonNullByDefault({}) ScriptEngine scriptEngine; + private @Mock @NonNullByDefault({}) ScriptContext scriptContext; + + private @NonNullByDefault({}) ScriptTransformationService service; + + @BeforeEach + public void setUp() throws ScriptException { + service = new ScriptTransformationService(transformationConfigurationRegistry, scriptEngineManager); + + when(scriptEngineManager.createScriptEngine(eq(SCRIPT_TYPE), any())).thenReturn(scriptEngineContainer); + when(scriptEngineManager.isSupported(anyString())) + .thenAnswer(scriptType -> SCRIPT_TYPE.equals(scriptType.getArgument(0))); + when(scriptEngineContainer.getScriptEngine()).thenReturn(scriptEngine); + when(scriptEngine.eval(SCRIPT)).thenReturn("output"); + when(scriptEngine.getContext()).thenReturn(scriptContext); + + when(transformationConfigurationRegistry.get(anyString())).thenAnswer( + scriptUid -> SCRIPT_UID.equals(scriptUid.getArgument(0)) ? TRANSFORMATION_CONFIGURATION : null); + } + + @Test + public void success() throws TransformationException { + String returnValue = service.transform(SCRIPT_TYPE + ":" + SCRIPT_UID, "input"); + + assertThat(returnValue, is(SCRIPT_OUTPUT)); + } + + @Test + public void scriptsAreCached() throws TransformationException { + service.transform(SCRIPT_TYPE + ":" + SCRIPT_UID, "input"); + service.transform(SCRIPT_TYPE + ":" + SCRIPT_UID, "input"); + + verify(transformationConfigurationRegistry).get(SCRIPT_UID); + } + + @Test + public void scriptCacheInvalidatedAfterChange() throws TransformationException { + service.transform(SCRIPT_TYPE + ":" + SCRIPT_UID, "input"); + service.updated(TRANSFORMATION_CONFIGURATION, TRANSFORMATION_CONFIGURATION); + service.transform(SCRIPT_TYPE + ":" + SCRIPT_UID, "input"); + + verify(transformationConfigurationRegistry, times(2)).get(SCRIPT_UID); + } + + @Test + public void noScriptTypeThrowsException() { + TransformationException e = assertThrows(TransformationException.class, + () -> service.transform(SCRIPT_UID, "input")); + + assertThat(e.getMessage(), is("Script Type must be prepended to transformation UID.")); + } + + @Test + public void unknownScriptTypeThrowsException() { + TransformationException e = assertThrows(TransformationException.class, + () -> service.transform("foo" + ":" + SCRIPT_UID, "input")); + + assertThat(e.getMessage(), is("Script type 'foo' is not supported by any available script engine.")); + } + + @Test + public void unknownScriptUidThrowsException() { + TransformationException e = assertThrows(TransformationException.class, + () -> service.transform(SCRIPT_TYPE + ":" + "foo", "input")); + + assertThat(e.getMessage(), is("Could not get script for UID 'foo'.")); + } + + @Test + public void scriptExceptionResultsInTransformationException() throws ScriptException { + when(scriptEngine.eval(SCRIPT)).thenThrow(new ScriptException("exception")); + + TransformationException e = assertThrows(TransformationException.class, + () -> service.transform(SCRIPT_TYPE + ":" + SCRIPT_UID, "input")); + + assertThat(e.getMessage(), is("Failed to execute script.")); + assertThat(e.getCause(), instanceOf(ScriptException.class)); + assertThat(e.getCause().getMessage(), is("exception")); + } +} From af3cded53b3745ace64ce73063c08c966e3107ad Mon Sep 17 00:00:00 2001 From: "Jan N. Klug" Date: Tue, 12 Apr 2022 18:46:01 +0200 Subject: [PATCH 2/3] resolve itests Signed-off-by: Jan N. Klug --- .../itest.bndrun | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/itests/org.openhab.core.automation.module.script.tests/itest.bndrun b/itests/org.openhab.core.automation.module.script.tests/itest.bndrun index e93837c43ae..0f0dc5ab74a 100644 --- a/itests/org.openhab.core.automation.module.script.tests/itest.bndrun +++ b/itests/org.openhab.core.automation.module.script.tests/itest.bndrun @@ -55,4 +55,5 @@ Fragment-Host: org.openhab.core.automation.module.script org.openhab.core.thing;version='[3.3.0,3.3.1)',\ org.ops4j.pax.logging.pax-logging-api;version='[2.0.14,2.0.15)',\ com.google.gson;version='[2.8.9,2.8.10)',\ - biz.aQute.tester.junit-platform;version='[6.2.0,6.2.1)' + biz.aQute.tester.junit-platform;version='[6.2.0,6.2.1)',\ + org.openhab.core.transform;version='[3.3.0,3.3.1)' From fbdd2c9428580096c76e5ef53ff0a0bd261b8376 Mon Sep 17 00:00:00 2001 From: "Jan N. Klug" Date: Sun, 24 Apr 2022 18:23:07 +0200 Subject: [PATCH 3/3] add parameters Signed-off-by: Jan N. Klug --- .../script/ScriptTransformationService.java | 73 +++++++++++++------ .../ScriptTransformationServiceTest.java | 22 +++++- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptTransformationService.java b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptTransformationService.java index 9c575b3b9a3..785b878d528 100644 --- a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptTransformationService.java +++ b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptTransformationService.java @@ -14,6 +14,8 @@ import java.util.HashMap; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.script.Compilable; import javax.script.CompiledScript; @@ -30,7 +32,10 @@ import org.openhab.core.transform.TransformationService; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * The {@link ScriptTransformationService} implements a {@link TransformationService} using any available script @@ -44,6 +49,11 @@ public class ScriptTransformationService implements TransformationService, RegistryChangeListener { public static final String OPENHAB_TRANSFORMATION_SCRIPT = "openhab-transformation-script-"; + private static final Pattern SCRIPT_CONFIG_PATTERN = Pattern + .compile("(?.*?):(?.*?)(\\?(?.*?))?"); + + private final Logger logger = LoggerFactory.getLogger(ScriptTransformationService.class); + private final Map scriptEngineContainers = new HashMap<>(); private final Map compiledScripts = new HashMap<>(); private final Map scriptCache = new HashMap<>(); @@ -57,16 +67,22 @@ public ScriptTransformationService( @Reference ScriptEngineManager scriptEngineManager) { this.transformationConfigurationRegistry = transformationConfigurationRegistry; this.scriptEngineManager = scriptEngineManager; + transformationConfigurationRegistry.addRegistryChangeListener(this); + } + + @Deactivate + public void deactivate() { + transformationConfigurationRegistry.removeRegistryChangeListener(this); } @Override public @Nullable String transform(String function, String source) throws TransformationException { - int splitPoint = function.indexOf(":"); - if (splitPoint < 1) { + Matcher configMatcher = SCRIPT_CONFIG_PATTERN.matcher(function); + if (!configMatcher.matches()) { throw new TransformationException("Script Type must be prepended to transformation UID."); } - String scriptType = function.substring(0, splitPoint); - String scriptUid = function.substring(splitPoint + 1); + String scriptType = configMatcher.group("scriptType"); + String scriptUid = configMatcher.group("scriptUid"); String script = scriptCache.get(scriptUid); if (script == null) { @@ -98,29 +114,39 @@ public ScriptTransformationService( if (scriptEngineContainer == null) { throw new TransformationException("Failed to create script engine container for '" + function + "'."); } - if (scriptEngineContainer != null) { - try { - CompiledScript compiledScript = this.compiledScripts.get(scriptUid); - - if (compiledScript == null && scriptEngineContainer.getScriptEngine() instanceof Compilable) { - // no compiled script available but compiling is supported - compiledScript = ((Compilable) scriptEngineContainer.getScriptEngine()).compile(script); - this.compiledScripts.put(scriptUid, compiledScript); - } + try { + CompiledScript compiledScript = this.compiledScripts.get(scriptUid); - ScriptEngine engine = compiledScript != null ? compiledScript.getEngine() - : scriptEngineContainer.getScriptEngine(); - ScriptContext executionContext = engine.getContext(); - executionContext.setAttribute("inputString", source, ScriptContext.ENGINE_SCOPE); + if (compiledScript == null && scriptEngineContainer.getScriptEngine() instanceof Compilable) { + // no compiled script available but compiling is supported + compiledScript = ((Compilable) scriptEngineContainer.getScriptEngine()).compile(script); + this.compiledScripts.put(scriptUid, compiledScript); + } - Object result = compiledScript != null ? compiledScript.eval() : engine.eval(script); - return result == null ? null : result.toString(); - } catch (ScriptException e) { - throw new TransformationException("Failed to execute script.", e); + ScriptEngine engine = compiledScript != null ? compiledScript.getEngine() + : scriptEngineContainer.getScriptEngine(); + ScriptContext executionContext = engine.getContext(); + executionContext.setAttribute("inputString", source, ScriptContext.ENGINE_SCOPE); + + String params = configMatcher.group("params"); + if (params != null) { + for (String param : params.split("&")) { + String[] splitString = param.split("="); + if (splitString.length != 2) { + logger.warn("Parameter '{}' does not consist of two parts for configuration UID {}, skipping.", + param, scriptUid); + + } else { + executionContext.setAttribute(splitString[0], splitString[1], ScriptContext.ENGINE_SCOPE); + } + } } - } - return null; + Object result = compiledScript != null ? compiledScript.eval() : engine.eval(script); + return result == null ? null : result.toString(); + } catch (ScriptException e) { + throw new TransformationException("Failed to execute script.", e); + } } @Override @@ -130,7 +156,6 @@ public void added(TransformationConfiguration element) { @Override public void removed(TransformationConfiguration element) { - clearCache(element.getUID()); } diff --git a/bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/ScriptTransformationServiceTest.java b/bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/ScriptTransformationServiceTest.java index 865655e0407..2c8fe60aa30 100644 --- a/bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/ScriptTransformationServiceTest.java +++ b/bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/ScriptTransformationServiceTest.java @@ -21,6 +21,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import javax.script.ScriptContext; @@ -46,7 +47,7 @@ */ @NonNullByDefault @ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.WARN) +@MockitoSettings(strictness = Strictness.LENIENT) public class ScriptTransformationServiceTest { private static final String COMPILABLE_SCRIPT_TYPE = "compilableScript"; private static final String SCRIPT_TYPE = "script"; @@ -86,6 +87,25 @@ public void success() throws TransformationException { assertThat(returnValue, is(SCRIPT_OUTPUT)); } + @Test + public void scriptExecutionParametersAreInjectedIntoEngineContext() throws TransformationException { + service.transform(SCRIPT_TYPE + ":" + SCRIPT_UID + "?param1=value1¶m2=value2", "input"); + + verify(scriptContext).setAttribute(eq("inputString"), eq("input"), eq(ScriptContext.ENGINE_SCOPE)); + verify(scriptContext).setAttribute(eq("param1"), eq("value1"), eq(ScriptContext.ENGINE_SCOPE)); + verify(scriptContext).setAttribute(eq("param2"), eq("value2"), eq(ScriptContext.ENGINE_SCOPE)); + verifyNoMoreInteractions(scriptContext); + } + + @Test + public void invalidScriptExecutionParametersAreDiscarded() throws TransformationException { + service.transform(SCRIPT_TYPE + ":" + SCRIPT_UID + "?param1=value1&invalid", "input"); + + verify(scriptContext).setAttribute(eq("inputString"), eq("input"), eq(ScriptContext.ENGINE_SCOPE)); + verify(scriptContext).setAttribute(eq("param1"), eq("value1"), eq(ScriptContext.ENGINE_SCOPE)); + verifyNoMoreInteractions(scriptContext); + } + @Test public void scriptsAreCached() throws TransformationException { service.transform(SCRIPT_TYPE + ":" + SCRIPT_UID, "input");