Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a generic script transformation #2883

Merged
merged 3 commits into from
Apr 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions bundles/org.openhab.core.automation.module.script/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Original file line number Diff line number Diff line change
@@ -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;
wborn marked this conversation as resolved.
Show resolved Hide resolved
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);
}
}
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)'