Add a generic script transformation (#2883)
Signed-off-by: Jan N. Klug <github@klug.nrw>pull/2932/head
parent
a42e798c8d
commit
a192a1dfc6
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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"));
|
||||
}
|
||||
}
|
|
@ -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)'
|
||||
|
|
Loading…
Reference in New Issue