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")); + } +}