diff --git a/bundles/org.openhab.core.automation.module.script/pom.xml b/bundles/org.openhab.core.automation.module.script/pom.xml index 1bdd6b8318..98314fe042 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 0000000000..785b878d52 --- /dev/null +++ b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptTransformationService.java @@ -0,0 +1,172 @@ +/** + * 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 java.util.regex.Matcher; +import java.util.regex.Pattern; + +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.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 + * 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 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<>(); + + private final TransformationConfigurationRegistry transformationConfigurationRegistry; + private final ScriptEngineManager scriptEngineManager; + + @Activate + public ScriptTransformationService( + @Reference TransformationConfigurationRegistry transformationConfigurationRegistry, + @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 { + Matcher configMatcher = SCRIPT_CONFIG_PATTERN.matcher(function); + if (!configMatcher.matches()) { + throw new TransformationException("Script Type must be prepended to transformation UID."); + } + String scriptType = configMatcher.group("scriptType"); + String scriptUid = configMatcher.group("scriptUid"); + + 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 + "'."); + } + 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); + + 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); + } + } + } + + 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 + 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 0000000000..2c8fe60aa3 --- /dev/null +++ b/bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/ScriptTransformationServiceTest.java @@ -0,0 +1,161 @@ +/** + * 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.verifyNoMoreInteractions; +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.LENIENT) +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 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"); + 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")); + } +} 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 e93837c43a..0f0dc5ab74 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)'