Add a generic script transformation (#2883)

Signed-off-by: Jan N. Klug <github@klug.nrw>
pull/2932/head
J-N-K 2022-04-24 19:00:03 +02:00 committed by GitHub
parent a42e798c8d
commit a192a1dfc6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 340 additions and 1 deletions

View File

@ -20,6 +20,11 @@
<artifactId>org.openhab.core.automation</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.transform</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>

View File

@ -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<TransformationConfiguration> {
public static final String OPENHAB_TRANSFORMATION_SCRIPT = "openhab-transformation-script-";
private static final Pattern SCRIPT_CONFIG_PATTERN = Pattern
.compile("(?<scriptType>.*?):(?<scriptUid>.*?)(\\?(?<params>.*?))?");
private final Logger logger = LoggerFactory.getLogger(ScriptTransformationService.class);
private final Map<String, ScriptEngineContainer> scriptEngineContainers = new HashMap<>();
private final Map<String, CompiledScript> compiledScripts = new HashMap<>();
private final Map<String, String> 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);
}
}

View File

@ -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&param2=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"));
}
}

View File

@ -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)'