diff --git a/bom/openhab-core/pom.xml b/bom/openhab-core/pom.xml
index 74e15de369..b4113644a7 100644
--- a/bom/openhab-core/pom.xml
+++ b/bom/openhab-core/pom.xml
@@ -238,6 +238,12 @@
${project.version}
compile
+
+ org.openhab.core.bundles
+ org.openhab.core.io.rest.transform
+ ${project.version}
+ compile
+
org.openhab.core.bundles
org.openhab.core.io.rest.ui
diff --git a/bundles/org.openhab.core.io.rest.transform/NOTICE b/bundles/org.openhab.core.io.rest.transform/NOTICE
new file mode 100644
index 0000000000..6c17d0d8a4
--- /dev/null
+++ b/bundles/org.openhab.core.io.rest.transform/NOTICE
@@ -0,0 +1,14 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-core
+
diff --git a/bundles/org.openhab.core.io.rest.transform/pom.xml b/bundles/org.openhab.core.io.rest.transform/pom.xml
new file mode 100644
index 0000000000..353d36e372
--- /dev/null
+++ b/bundles/org.openhab.core.io.rest.transform/pom.xml
@@ -0,0 +1,30 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.core.bundles
+ org.openhab.core.reactor.bundles
+ 3.3.0-SNAPSHOT
+
+
+ org.openhab.core.io.rest.transform
+
+ openHAB Core :: Bundles :: Transformation REST Interface
+
+
+
+ org.openhab.core.bundles
+ org.openhab.core.transform
+ ${project.version}
+
+
+ org.openhab.core.bundles
+ org.openhab.core.io.rest
+ ${project.version}
+
+
+
+
diff --git a/bundles/org.openhab.core.io.rest.transform/src/main/java/org/openhab/core/io/rest/transform/TransformationConfigurationDTO.java b/bundles/org.openhab.core.io.rest.transform/src/main/java/org/openhab/core/io/rest/transform/TransformationConfigurationDTO.java
new file mode 100644
index 0000000000..d692586ba3
--- /dev/null
+++ b/bundles/org.openhab.core.io.rest.transform/src/main/java/org/openhab/core/io/rest/transform/TransformationConfigurationDTO.java
@@ -0,0 +1,40 @@
+/**
+ * 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.io.rest.transform;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.transform.TransformationConfiguration;
+
+/**
+ * The {@link TransformationConfigurationDTO} wraps a {@link TransformationConfiguration}
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public class TransformationConfigurationDTO {
+ public String uid;
+ public String label;
+ public String type;
+ public @Nullable String language;
+ public String content;
+ public boolean editable = false;
+
+ public TransformationConfigurationDTO(TransformationConfiguration transformationConfiguration) {
+ this.uid = transformationConfiguration.getUID();
+ this.label = transformationConfiguration.getLabel();
+ this.type = transformationConfiguration.getType();
+ this.content = transformationConfiguration.getContent();
+ this.language = transformationConfiguration.getLanguage();
+ }
+}
diff --git a/bundles/org.openhab.core.io.rest.transform/src/main/java/org/openhab/core/io/rest/transform/internal/TransformationConfigurationResource.java b/bundles/org.openhab.core.io.rest.transform/src/main/java/org/openhab/core/io/rest/transform/internal/TransformationConfigurationResource.java
new file mode 100644
index 0000000000..66d211ad84
--- /dev/null
+++ b/bundles/org.openhab.core.io.rest.transform/src/main/java/org/openhab/core/io/rest/transform/internal/TransformationConfigurationResource.java
@@ -0,0 +1,192 @@
+/**
+ * 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.io.rest.transform.internal;
+
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import javax.annotation.security.RolesAllowed;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.auth.Role;
+import org.openhab.core.io.rest.RESTConstants;
+import org.openhab.core.io.rest.RESTResource;
+import org.openhab.core.io.rest.Stream2JSONInputStream;
+import org.openhab.core.io.rest.transform.TransformationConfigurationDTO;
+import org.openhab.core.transform.ManagedTransformationConfigurationProvider;
+import org.openhab.core.transform.TransformationConfiguration;
+import org.openhab.core.transform.TransformationConfigurationRegistry;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.jaxrs.whiteboard.JaxrsWhiteboardConstants;
+import org.osgi.service.jaxrs.whiteboard.propertytypes.JSONRequired;
+import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsApplicationSelect;
+import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsName;
+import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.ArraySchema;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+/**
+ * The {@link TransformationConfigurationResource} is a REST resource for handling transformation configurations
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@Component(immediate = true)
+@JaxrsResource
+@JaxrsName(TransformationConfigurationResource.PATH_TRANSFORMATIONS)
+@JaxrsApplicationSelect("(" + JaxrsWhiteboardConstants.JAX_RS_NAME + "=" + RESTConstants.JAX_RS_NAME + ")")
+@JSONRequired
+@Path(TransformationConfigurationResource.PATH_TRANSFORMATIONS)
+@RolesAllowed({ Role.ADMIN })
+@SecurityRequirement(name = "oauth2", scopes = { "admin" })
+@Tag(name = TransformationConfigurationResource.PATH_TRANSFORMATIONS)
+@NonNullByDefault
+public class TransformationConfigurationResource implements RESTResource {
+ public static final String PATH_TRANSFORMATIONS = "transformations";
+
+ private final Logger logger = LoggerFactory.getLogger(TransformationConfigurationResource.class);
+ private final TransformationConfigurationRegistry transformationConfigurationRegistry;
+ private final ManagedTransformationConfigurationProvider managedTransformationConfigurationProvider;
+ private @Context @NonNullByDefault({}) UriInfo uriInfo;
+
+ @Activate
+ public TransformationConfigurationResource(
+ final @Reference TransformationConfigurationRegistry transformationConfigurationRegistry,
+ final @Reference ManagedTransformationConfigurationProvider managedTransformationConfigurationProvider) {
+ this.transformationConfigurationRegistry = transformationConfigurationRegistry;
+ this.managedTransformationConfigurationProvider = managedTransformationConfigurationProvider;
+ }
+
+ @GET
+ @Path("configurations")
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(operationId = "getTransformationConfigurations", summary = "Get a list of all transformation configurations", responses = {
+ @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = TransformationConfigurationDTO.class)))) })
+ public Response getTransformationConfigurations() {
+ logger.debug("Received HTTP GET request at '{}'", uriInfo.getPath());
+ Stream stream = transformationConfigurationRegistry.stream()
+ .map(TransformationConfigurationDTO::new).peek(c -> c.editable = isEditable(c.uid));
+ return Response.ok(new Stream2JSONInputStream(stream)).build();
+ }
+
+ @GET
+ @Path("configurations/{uid}")
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(operationId = "getTransformationConfiguration", summary = "Get a single transformation configuration", responses = {
+ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = TransformationConfiguration.class))),
+ @ApiResponse(responseCode = "404", description = "Not found") })
+ public Response getTransformationConfiguration(
+ @PathParam("uid") @Parameter(description = "Configuration UID") String uid) {
+ logger.debug("Received HTTP GET request at '{}'", uriInfo.getPath());
+
+ TransformationConfiguration configuration = transformationConfigurationRegistry.get(uid);
+ if (configuration == null) {
+ return Response.status(Response.Status.NOT_FOUND).build();
+ }
+ return Response.ok(configuration).build();
+ }
+
+ @PUT
+ @Path("configurations/{uid}")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.TEXT_PLAIN)
+ @Operation(operationId = "putTransformationConfiguration", summary = "Get a single transformation configuration", responses = {
+ @ApiResponse(responseCode = "200", description = "OK"),
+ @ApiResponse(responseCode = "400", description = "Bad Request (content missing or invalid)"),
+ @ApiResponse(responseCode = "405", description = "Configuration not editable") })
+ public Response putTransformationConfiguration(
+ @PathParam("uid") @Parameter(description = "Configuration UID") String uid,
+ @Parameter(description = "configuration", required = true) @Nullable TransformationConfigurationDTO newConfiguration) {
+ logger.debug("Received HTTP PUT request at '{}'", uriInfo.getPath());
+
+ TransformationConfiguration oldConfiguration = transformationConfigurationRegistry.get(uid);
+ if (oldConfiguration != null && !isEditable(uid)) {
+ return Response.status(Response.Status.METHOD_NOT_ALLOWED).build();
+ }
+
+ if (newConfiguration == null) {
+ return Response.status(Response.Status.BAD_REQUEST).entity("Content missing.").build();
+ }
+
+ if (!uid.equals(newConfiguration.uid)) {
+ return Response.status(Response.Status.BAD_REQUEST).entity("UID of configuration and path not matching.")
+ .build();
+ }
+
+ TransformationConfiguration transformationConfiguration = new TransformationConfiguration(newConfiguration.uid,
+ newConfiguration.label, newConfiguration.type, newConfiguration.language, newConfiguration.content);
+ try {
+ if (oldConfiguration != null) {
+ managedTransformationConfigurationProvider.update(transformationConfiguration);
+ } else {
+ managedTransformationConfigurationProvider.add(transformationConfiguration);
+ }
+ } catch (IllegalArgumentException e) {
+ return Response.status(Response.Status.BAD_REQUEST).entity(Objects.requireNonNullElse(e.getMessage(), ""))
+ .build();
+ }
+
+ return Response.ok().build();
+ }
+
+ @DELETE
+ @Path("configurations/{uid}")
+ @Produces(MediaType.TEXT_PLAIN)
+ @Operation(operationId = "deleteTransformationConfiguration", summary = "Get a single transformation configuration", responses = {
+ @ApiResponse(responseCode = "200", description = "OK"),
+ @ApiResponse(responseCode = "404", description = "UID not found"),
+ @ApiResponse(responseCode = "405", description = "Configuration not editable") })
+ public Response deleteTransformationConfiguration(
+ @PathParam("uid") @Parameter(description = "Configuration UID") String uid) {
+ logger.debug("Received HTTP DELETE request at '{}'", uriInfo.getPath());
+
+ TransformationConfiguration oldConfiguration = transformationConfigurationRegistry.get(uid);
+ if (oldConfiguration == null) {
+ return Response.status(Response.Status.NOT_FOUND).build();
+ }
+
+ if (!isEditable(uid)) {
+ return Response.status(Response.Status.METHOD_NOT_ALLOWED).build();
+ }
+
+ managedTransformationConfigurationProvider.remove(uid);
+
+ return Response.ok().build();
+ }
+
+ private boolean isEditable(String uid) {
+ return managedTransformationConfigurationProvider.get(uid) != null;
+ }
+}
diff --git a/bundles/org.openhab.core.transform/pom.xml b/bundles/org.openhab.core.transform/pom.xml
index 5b70829f4d..0cee63b00d 100644
--- a/bundles/org.openhab.core.transform/pom.xml
+++ b/bundles/org.openhab.core.transform/pom.xml
@@ -20,6 +20,12 @@
org.openhab.core.config.core
${project.version}
+
+ org.openhab.core.bundles
+ org.openhab.core.test
+ ${project.version}
+ test
+
diff --git a/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/AbstractFileTransformationService.java b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/AbstractFileTransformationService.java
index 9f7907a2a0..d522e330b2 100644
--- a/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/AbstractFileTransformationService.java
+++ b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/AbstractFileTransformationService.java
@@ -49,6 +49,8 @@ import org.slf4j.LoggerFactory;
* under the 'transform' folder within the configuration path. To organize the various
* transformations one might use subfolders.
*
+ * @deprecated use the {@link TransformationConfigurationRegistry} instead
+ *
* @author Gaƫl L'hopital - Initial contribution
* @author Kai Kreuzer - File caching mechanism
* @author Markus Rathgeb - Add locale provider support
diff --git a/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/FileTransformationConfigurationProvider.java b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/FileTransformationConfigurationProvider.java
new file mode 100644
index 0000000000..2dd45c2afa
--- /dev/null
+++ b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/FileTransformationConfigurationProvider.java
@@ -0,0 +1,156 @@
+/**
+ * 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.transform;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardWatchEventKinds;
+import java.nio.file.WatchEvent;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.OpenHAB;
+import org.openhab.core.common.registry.ProviderChangeListener;
+import org.openhab.core.service.AbstractWatchService;
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link FileTransformationConfigurationProvider} implements a {@link TransformationConfigurationProvider} for
+ * supporting configurations stored in configuration files
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = TransformationConfigurationProvider.class, immediate = true)
+public class FileTransformationConfigurationProvider extends AbstractWatchService
+ implements TransformationConfigurationProvider {
+ private static final WatchEvent.Kind>[] WATCH_EVENTS = { StandardWatchEventKinds.ENTRY_CREATE,
+ StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_MODIFY };
+ private static final Set IGNORED_EXTENSIONS = Set.of("txt");
+ private static final Pattern FILENAME_PATTERN = Pattern
+ .compile("(?.+?)(_(?[a-z]{2}))?\\.(?[^.]*)$");
+ private static final Path TRANSFORMATION_PATH = Path.of(OpenHAB.getConfigFolder(),
+ TransformationService.TRANSFORM_FOLDER_NAME);
+
+ private final Logger logger = LoggerFactory.getLogger(FileTransformationConfigurationProvider.class);
+
+ private final Set> listeners = ConcurrentHashMap.newKeySet();
+ private final Map transformationConfigurations = new ConcurrentHashMap<>();
+ private final Path transformationPath;
+
+ public FileTransformationConfigurationProvider() {
+ this(TRANSFORMATION_PATH);
+ }
+
+ // constructor package private used for testing
+ FileTransformationConfigurationProvider(Path transformationPath) {
+ super(transformationPath.toString());
+ this.transformationPath = transformationPath;
+
+ // read initial contents
+ try {
+ Files.walk(transformationPath).filter(Files::isRegularFile)
+ .forEach(f -> processPath(StandardWatchEventKinds.ENTRY_CREATE, f));
+ } catch (IOException e) {
+ logger.warn("Could not list files in '{}', transformation configurations might be missing: {}",
+ transformationPath, e.getMessage());
+ }
+ }
+
+ @Override
+ public void addProviderChangeListener(ProviderChangeListener listener) {
+ listeners.add(listener);
+ }
+
+ @Override
+ public void removeProviderChangeListener(ProviderChangeListener listener) {
+ listeners.remove(listener);
+ }
+
+ @Override
+ public Collection getAll() {
+ return transformationConfigurations.values();
+ }
+
+ @Override
+ protected boolean watchSubDirectories() {
+ return true;
+ }
+
+ @Override
+ protected WatchEvent.Kind> @Nullable [] getWatchEventKinds(Path directory) {
+ return WATCH_EVENTS;
+ }
+
+ @Override
+ protected void processWatchEvent(WatchEvent> event, WatchEvent.Kind> kind, Path path) {
+ processPath(kind, path);
+ }
+
+ private void processPath(WatchEvent.Kind> kind, Path path) {
+ if (!Files.isRegularFile(path)) {
+ logger.trace("Skipping {} event for '{}' - not a regular file", kind, path);
+ return;
+ }
+ if (StandardWatchEventKinds.ENTRY_DELETE.equals(kind)) {
+ TransformationConfiguration oldElement = transformationConfigurations.remove(path);
+ if (oldElement != null) {
+ logger.trace("Removed configuration from file '{}", path);
+ listeners.forEach(listener -> listener.removed(this, oldElement));
+ }
+ } else if (StandardWatchEventKinds.ENTRY_CREATE.equals(kind)
+ || StandardWatchEventKinds.ENTRY_MODIFY.equals(kind)) {
+ try {
+ String fileName = path.getFileName().toString();
+ Matcher m = FILENAME_PATTERN.matcher(fileName);
+ if (!m.matches()) {
+ logger.debug("Skipping {} event for '{}' - no file extensions found or remaining filename empty",
+ kind, path);
+ return;
+ }
+
+ String fileExtension = m.group("extension");
+ if (IGNORED_EXTENSIONS.contains(fileExtension)) {
+ logger.debug("Skipping {} event for '{}' - file extension '{}' is ignored", kind, path,
+ fileExtension);
+ return;
+ }
+
+ String content = new String(Files.readAllBytes(path));
+ String uid = transformationPath.relativize(path).toString();
+
+ TransformationConfiguration newElement = new TransformationConfiguration(uid, uid, fileExtension,
+ m.group("language"), content);
+ TransformationConfiguration oldElement = transformationConfigurations.put(path, newElement);
+ if (oldElement == null) {
+ logger.trace("Added new configuration from file '{}'", path);
+ listeners.forEach(listener -> listener.added(this, newElement));
+ } else {
+ logger.trace("Updated new configuration from file '{}'", path);
+ listeners.forEach(listener -> listener.updated(this, oldElement, newElement));
+ }
+ } catch (IOException e) {
+ logger.warn("Skipping {} event for '{}' - failed to read content: {}", kind, path, e.getMessage());
+ }
+ }
+ }
+}
diff --git a/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/ManagedTransformationConfigurationProvider.java b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/ManagedTransformationConfigurationProvider.java
new file mode 100644
index 0000000000..35efc67c57
--- /dev/null
+++ b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/ManagedTransformationConfigurationProvider.java
@@ -0,0 +1,114 @@
+/**
+ * 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.transform;
+
+import java.util.Objects;
+import java.util.regex.Matcher;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.common.registry.AbstractManagedProvider;
+import org.openhab.core.storage.StorageService;
+import org.openhab.core.transform.ManagedTransformationConfigurationProvider.PersistedTransformationConfiguration;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link ManagedTransformationConfigurationProvider} implements a {@link TransformationConfigurationProvider} for
+ * managed configurations stored in a JSON database
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = { TransformationConfigurationProvider.class,
+ ManagedTransformationConfigurationProvider.class }, immediate = true)
+public class ManagedTransformationConfigurationProvider
+ extends AbstractManagedProvider
+ implements TransformationConfigurationProvider {
+
+ @Activate
+ public ManagedTransformationConfigurationProvider(final @Reference StorageService storageService) {
+ super(storageService);
+ }
+
+ @Override
+ protected String getStorageName() {
+ return TransformationConfiguration.class.getName();
+ }
+
+ @Override
+ protected String keyToString(String key) {
+ return key;
+ }
+
+ @Override
+ protected @Nullable TransformationConfiguration toElement(String key,
+ PersistedTransformationConfiguration persistableElement) {
+ return new TransformationConfiguration(persistableElement.uid, persistableElement.label,
+ persistableElement.type, persistableElement.language, persistableElement.content);
+ }
+
+ @Override
+ protected PersistedTransformationConfiguration toPersistableElement(TransformationConfiguration element) {
+ return new PersistedTransformationConfiguration(element);
+ }
+
+ @Override
+ public void add(TransformationConfiguration element) {
+ checkConfiguration(element);
+ super.add(element);
+ }
+
+ @Override
+ public @Nullable TransformationConfiguration update(TransformationConfiguration element) {
+ checkConfiguration(element);
+ return super.update(element);
+ }
+
+ private static void checkConfiguration(TransformationConfiguration element) {
+ Matcher matcher = TransformationConfigurationRegistry.CONFIG_UID_PATTERN.matcher(element.getUID());
+ if (!matcher.matches()) {
+ throw new IllegalArgumentException(
+ "The transformation configuration UID '" + element.getUID() + "' is invalid.");
+ }
+ if (!Objects.equals(element.getLanguage(), matcher.group("language"))) {
+ throw new IllegalArgumentException("The transformation configuration UID '" + element.getUID()
+ + "' contains(misses) a language, but it is not set (set).");
+ }
+ if (!Objects.equals(element.getType(), matcher.group("type"))) {
+ throw new IllegalArgumentException("The transformation configuration UID '" + element.getUID()
+ + "' is not matching the type '" + element.getType() + "'.");
+ }
+ }
+
+ public static class PersistedTransformationConfiguration {
+ public @NonNullByDefault({}) String uid;
+ public @NonNullByDefault({}) String label;
+ public @NonNullByDefault({}) String type;
+ public @Nullable String language;
+ public @NonNullByDefault({}) String content;
+
+ protected PersistedTransformationConfiguration() {
+ // default constructor for deserialization
+ }
+
+ public PersistedTransformationConfiguration(TransformationConfiguration configuration) {
+ this.uid = configuration.getUID();
+ this.label = configuration.getLabel();
+ this.type = configuration.getType();
+ this.language = configuration.getLanguage();
+ this.content = configuration.getContent();
+ }
+ }
+}
diff --git a/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/TransformationConfiguration.java b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/TransformationConfiguration.java
new file mode 100644
index 0000000000..67f664b4e7
--- /dev/null
+++ b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/TransformationConfiguration.java
@@ -0,0 +1,94 @@
+/**
+ * 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.transform;
+
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.common.registry.Identifiable;
+
+/**
+ * The {@link TransformationConfiguration} encapsulates a transformation configuration
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public class TransformationConfiguration implements Identifiable {
+ private final String uid;
+ private final String label;
+ private final String type;
+ private final @Nullable String language;
+ private final String content;
+
+ /**
+ * @param uid the configuration UID. The format is config:<type>:<name>[:<locale>]. For backward
+ * compatibility also filenames are allowed.
+ * @param type the type of the configuration (file extension for file-based providers)
+ * @param language the language of this configuration (null
if not set)
+ * @param content the content of this configuration
+ */
+ public TransformationConfiguration(String uid, String label, String type, @Nullable String language,
+ String content) {
+ this.uid = uid;
+ this.label = label;
+ this.type = type;
+ this.content = content;
+ this.language = language;
+ }
+
+ @Override
+ public String getUID() {
+ return uid;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public @Nullable String getLanguage() {
+ return language;
+ }
+
+ public String getContent() {
+ return content;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ TransformationConfiguration that = (TransformationConfiguration) o;
+ return uid.equals(that.uid) && label.equals(that.label) && type.equals(that.type)
+ && Objects.equals(language, that.language) && content.equals(that.content);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(uid, label, type, language, content);
+ }
+
+ @Override
+ public String toString() {
+ return "TransformationConfiguration{uid='" + uid + "', label='" + label + "', type='" + type + "', language='"
+ + language + "', content='" + content + "'}";
+ }
+}
diff --git a/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/TransformationConfigurationProvider.java b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/TransformationConfigurationProvider.java
new file mode 100644
index 0000000000..a3f9ec3390
--- /dev/null
+++ b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/TransformationConfigurationProvider.java
@@ -0,0 +1,26 @@
+/**
+ * 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.transform;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.common.registry.Provider;
+
+/**
+ * The {@link TransformationConfigurationProvider} is implemented by providers for transformation configurations
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public interface TransformationConfigurationProvider extends Provider {
+
+}
diff --git a/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/TransformationConfigurationRegistry.java b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/TransformationConfigurationRegistry.java
new file mode 100644
index 0000000000..1ae5cc3406
--- /dev/null
+++ b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/TransformationConfigurationRegistry.java
@@ -0,0 +1,49 @@
+/**
+ * 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.transform;
+
+import java.util.Collection;
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.common.registry.Registry;
+
+/**
+ * The {@link TransformationConfigurationRegistry} is the interface for the transformation configuration registry
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public interface TransformationConfigurationRegistry extends Registry {
+ Pattern CONFIG_UID_PATTERN = Pattern.compile("config:(?\\w+):(?\\w+)(:(?\\w+))?");
+
+ /**
+ * Get a localized version of the configuration for a given UID
+ *
+ * @param uid the configuration UID
+ * @param locale a locale (system locale is used if null
)
+ * @return the requested {@link TransformationConfiguration} (or null
if not found).
+ */
+ @Nullable
+ TransformationConfiguration get(String uid, @Nullable Locale locale);
+
+ /**
+ * Get all configurations which match the given types
+ *
+ * @param types a {@link Collection} of configuration types
+ * @return a {@link Collection} of {@link TransformationConfiguration}s
+ */
+ Collection getConfigurations(Collection types);
+}
diff --git a/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/internal/TransformationActivator.java b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/internal/TransformationActivator.java
index a1f11be4b2..e052661bfc 100644
--- a/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/internal/TransformationActivator.java
+++ b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/internal/TransformationActivator.java
@@ -12,6 +12,8 @@
*/
package org.openhab.core.transform.internal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
import org.osgi.framework.BundleActivator;
import org.osgi.framework.BundleContext;
import org.slf4j.Logger;
@@ -23,17 +25,18 @@ import org.slf4j.LoggerFactory;
* @author Thomas Eichstaedt-Engelen - Initial contribution
* @author Kai Kreuzer - Initial contribution
*/
+@NonNullByDefault
public final class TransformationActivator implements BundleActivator {
private final Logger logger = LoggerFactory.getLogger(TransformationActivator.class);
- private static BundleContext context;
+ private static @Nullable BundleContext context;
/**
* Called whenever the OSGi framework starts our bundle
*/
@Override
- public void start(BundleContext bc) throws Exception {
+ public void start(@Nullable BundleContext bc) throws Exception {
context = bc;
logger.debug("Transformation Service has been started.");
}
@@ -42,7 +45,7 @@ public final class TransformationActivator implements BundleActivator {
* Called whenever the OSGi framework stops our bundle
*/
@Override
- public void stop(BundleContext bc) throws Exception {
+ public void stop(@Nullable BundleContext bc) throws Exception {
context = null;
logger.debug("Transformation Service has been stopped.");
}
@@ -52,7 +55,7 @@ public final class TransformationActivator implements BundleActivator {
*
* @return the bundle context
*/
- public static BundleContext getContext() {
+ public static @Nullable BundleContext getContext() {
return context;
}
}
diff --git a/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/internal/TransformationConfigurationRegistryImpl.java b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/internal/TransformationConfigurationRegistryImpl.java
new file mode 100644
index 0000000000..d86d5431e6
--- /dev/null
+++ b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/internal/TransformationConfigurationRegistryImpl.java
@@ -0,0 +1,103 @@
+/**
+ * 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.transform.internal;
+
+import java.util.Collection;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.common.registry.AbstractRegistry;
+import org.openhab.core.common.registry.Provider;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.transform.ManagedTransformationConfigurationProvider;
+import org.openhab.core.transform.TransformationConfiguration;
+import org.openhab.core.transform.TransformationConfigurationProvider;
+import org.openhab.core.transform.TransformationConfigurationRegistry;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.component.annotations.ReferenceCardinality;
+import org.osgi.service.component.annotations.ReferencePolicy;
+
+/**
+ * The {@link TransformationConfigurationRegistryImpl} implements the {@link TransformationConfigurationRegistry}
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+@Component(immediate = true)
+public class TransformationConfigurationRegistryImpl
+ extends AbstractRegistry
+ implements TransformationConfigurationRegistry {
+ private static final Pattern FILENAME_PATTERN = Pattern
+ .compile("(?.+)(_(?[a-z]{2}))?\\.(?[^.]*)$");
+
+ private final LocaleProvider localeProvider;
+
+ @Activate
+ public TransformationConfigurationRegistryImpl(@Reference LocaleProvider localeProvider) {
+ super(TransformationConfigurationProvider.class);
+
+ this.localeProvider = localeProvider;
+ }
+
+ @Override
+ public @Nullable TransformationConfiguration get(String uid, @Nullable Locale locale) {
+ TransformationConfiguration configuration = null;
+
+ String language = Objects.requireNonNullElse(locale, localeProvider.getLocale()).getLanguage();
+ Matcher uidMatcher = CONFIG_UID_PATTERN.matcher(uid);
+ if (uidMatcher.matches()) {
+ // try to get localized version of the uid if no locale information is present
+ if (uidMatcher.group("language") == null) {
+ configuration = get(uid + ":" + language);
+ }
+ } else {
+ // check if legacy configuration and try to get localized version
+ uidMatcher = FILENAME_PATTERN.matcher(uid);
+ if (uidMatcher.matches() && uidMatcher.group("language") == null) {
+ // try to get a localized version
+ String localizedUid = uidMatcher.group("filename") + "_" + language + "."
+ + uidMatcher.group("extension");
+ configuration = get(localizedUid);
+ }
+ }
+
+ return (configuration != null) ? configuration : get(uid);
+ }
+
+ @Override
+ public Collection getConfigurations(Collection types) {
+ return getAll().stream().filter(e -> types.contains(e.getType())).collect(Collectors.toList());
+ }
+
+ @Reference(cardinality = ReferenceCardinality.OPTIONAL, policy = ReferencePolicy.DYNAMIC)
+ protected void setManagedProvider(ManagedTransformationConfigurationProvider provider) {
+ super.setManagedProvider(provider);
+ }
+
+ protected void unsetManagedProvider(ManagedTransformationConfigurationProvider provider) {
+ super.unsetManagedProvider(provider);
+ }
+
+ @Override
+ protected void addProvider(Provider provider) {
+ // overridden to make method available for testing
+ super.addProvider(provider);
+ }
+}
diff --git a/bundles/org.openhab.core.transform/src/test/java/org/openhab/core/transform/FileTransformationConfigurationProviderTest.java b/bundles/org.openhab.core.transform/src/test/java/org/openhab/core/transform/FileTransformationConfigurationProviderTest.java
new file mode 100644
index 0000000000..bd26fe9b0d
--- /dev/null
+++ b/bundles/org.openhab.core.transform/src/test/java/org/openhab/core/transform/FileTransformationConfigurationProviderTest.java
@@ -0,0 +1,167 @@
+/**
+ * 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.transform;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.not;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardWatchEventKinds;
+import java.nio.file.WatchEvent;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.AfterEach;
+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.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.common.registry.ProviderChangeListener;
+
+/**
+ * The {@link FileTransformationConfigurationProviderTest} includes tests for the
+ * {@link FileTransformationConfigurationProvider}
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@NonNullByDefault
+public class FileTransformationConfigurationProviderTest {
+ private static final String FOO_TYPE = "foo";
+ private static final String INITIAL_CONTENT = "initial";
+ private static final String INITIAL_FILENAME = INITIAL_CONTENT + "." + FOO_TYPE;
+ private static final TransformationConfiguration INITIAL_CONFIGURATION = new TransformationConfiguration(
+ INITIAL_FILENAME, INITIAL_FILENAME, FOO_TYPE, null, INITIAL_CONTENT);
+ private static final String ADDED_CONTENT = "added";
+ private static final String ADDED_FILENAME = ADDED_CONTENT + "." + FOO_TYPE;
+
+ private @Mock @NonNullByDefault({}) WatchEvent watchEventMock;
+ private @Mock @NonNullByDefault({}) ProviderChangeListener<@NonNull TransformationConfiguration> listenerMock;
+
+ private @NonNullByDefault({}) FileTransformationConfigurationProvider provider;
+ private @NonNullByDefault({}) Path targetPath;
+
+ @BeforeEach
+ public void setup() throws IOException {
+ // create directory
+ targetPath = Files.createTempDirectory("fileTest");
+ // set initial content
+ Files.write(targetPath.resolve(INITIAL_FILENAME), INITIAL_CONTENT.getBytes(StandardCharsets.UTF_8));
+
+ provider = new FileTransformationConfigurationProvider(targetPath);
+ provider.addProviderChangeListener(listenerMock);
+ }
+
+ @AfterEach
+ public void tearDown() throws IOException {
+ try (Stream walk = Files.walk(targetPath)) {
+ walk.map(Path::toFile).forEach(File::delete);
+ }
+ Files.deleteIfExists(targetPath);
+ }
+
+ @Test
+ public void testInitialConfigurationIsPresent() {
+ // assert that initial configuration is present
+ assertThat(provider.getAll(), contains(INITIAL_CONFIGURATION));
+ }
+
+ @Test
+ public void testAddingConfigurationIsPropagated() throws IOException {
+ Path path = targetPath.resolve(ADDED_FILENAME);
+
+ Files.write(path, ADDED_CONTENT.getBytes(StandardCharsets.UTF_8));
+ TransformationConfiguration addedConfiguration = new TransformationConfiguration(ADDED_FILENAME, ADDED_FILENAME,
+ FOO_TYPE, null, ADDED_CONTENT);
+
+ provider.processWatchEvent(watchEventMock, StandardWatchEventKinds.ENTRY_CREATE, path);
+
+ // assert registry is notified and internal cache updated
+ Mockito.verify(listenerMock).added(provider, addedConfiguration);
+ assertThat(provider.getAll(), hasItem(addedConfiguration));
+ }
+
+ @Test
+ public void testUpdatingConfigurationIsPropagated() throws IOException {
+ Path path = targetPath.resolve(INITIAL_FILENAME);
+ Files.write(path, "updated".getBytes(StandardCharsets.UTF_8));
+ TransformationConfiguration updatedConfiguration = new TransformationConfiguration(INITIAL_FILENAME,
+ INITIAL_FILENAME, FOO_TYPE, null, "updated");
+
+ provider.processWatchEvent(watchEventMock, StandardWatchEventKinds.ENTRY_MODIFY, path);
+
+ Mockito.verify(listenerMock).updated(provider, INITIAL_CONFIGURATION, updatedConfiguration);
+ assertThat(provider.getAll(), contains(updatedConfiguration));
+ assertThat(provider.getAll(), not(contains(INITIAL_CONFIGURATION)));
+ }
+
+ @Test
+ public void testDeletingConfigurationIsPropagated() {
+ Path path = targetPath.resolve(INITIAL_FILENAME);
+
+ provider.processWatchEvent(watchEventMock, StandardWatchEventKinds.ENTRY_DELETE, path);
+
+ Mockito.verify(listenerMock).removed(provider, INITIAL_CONFIGURATION);
+ assertThat(provider.getAll(), not(contains(INITIAL_CONFIGURATION)));
+ }
+
+ @Test
+ public void testLanguageIsProperlyParsed() throws IOException {
+ String fileName = "test_de." + FOO_TYPE;
+ Path path = targetPath.resolve(fileName);
+
+ Files.write(path, INITIAL_CONTENT.getBytes(StandardCharsets.UTF_8));
+
+ TransformationConfiguration expected = new TransformationConfiguration(fileName, fileName, FOO_TYPE, "de",
+ INITIAL_CONTENT);
+
+ provider.processWatchEvent(watchEventMock, StandardWatchEventKinds.ENTRY_CREATE, path);
+ assertThat(provider.getAll(), hasItem(expected));
+ }
+
+ @Test
+ public void testMissingExtensionIsIgnored() throws IOException {
+ Path path = targetPath.resolve("extensionMissing");
+ Files.write(path, INITIAL_CONTENT.getBytes(StandardCharsets.UTF_8));
+ provider.processWatchEvent(watchEventMock, StandardWatchEventKinds.ENTRY_CREATE, path);
+ provider.processWatchEvent(watchEventMock, StandardWatchEventKinds.ENTRY_MODIFY, path);
+
+ Mockito.verify(listenerMock, never()).added(any(), any());
+ Mockito.verify(listenerMock, never()).updated(any(), any(), any());
+ }
+
+ @Test
+ public void testIgnoredExtensionIsIgnored() throws IOException {
+ Path path = targetPath.resolve("extensionIgnore.txt");
+ Files.write(path, INITIAL_CONTENT.getBytes(StandardCharsets.UTF_8));
+ provider.processWatchEvent(watchEventMock, StandardWatchEventKinds.ENTRY_CREATE, path);
+ provider.processWatchEvent(watchEventMock, StandardWatchEventKinds.ENTRY_MODIFY, path);
+
+ Mockito.verify(listenerMock, never()).added(any(), any());
+ Mockito.verify(listenerMock, never()).updated(any(), any(), any());
+ }
+}
diff --git a/bundles/org.openhab.core.transform/src/test/java/org/openhab/core/transform/ManagedTransformationConfigurationProviderTest.java b/bundles/org.openhab.core.transform/src/test/java/org/openhab/core/transform/ManagedTransformationConfigurationProviderTest.java
new file mode 100644
index 0000000000..2c15e77037
--- /dev/null
+++ b/bundles/org.openhab.core.transform/src/test/java/org/openhab/core/transform/ManagedTransformationConfigurationProviderTest.java
@@ -0,0 +1,126 @@
+/**
+ * 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.transform;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.eclipse.jdt.annotation.NonNull;
+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.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.common.registry.ProviderChangeListener;
+import org.openhab.core.test.storage.VolatileStorageService;
+
+/**
+ * The {@link ManagedTransformationConfigurationProviderTest} includes tests for the
+ * {@link org.openhab.core.transform.ManagedTransformationConfigurationProvider}
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@NonNullByDefault
+public class ManagedTransformationConfigurationProviderTest {
+
+ private @Mock @NonNullByDefault({}) ProviderChangeListener<@NonNull TransformationConfiguration> listenerMock;
+
+ private @NonNullByDefault({}) ManagedTransformationConfigurationProvider provider;
+
+ @BeforeEach
+ public void setup() {
+ VolatileStorageService storageService = new VolatileStorageService();
+ provider = new ManagedTransformationConfigurationProvider(storageService);
+ provider.addProviderChangeListener(listenerMock);
+ }
+
+ @Test
+ public void testValidConfigurationsAreAdded() {
+ TransformationConfiguration withoutLanguage = new TransformationConfiguration("config:foo:identifier", "",
+ "foo", null, "content");
+ provider.add(withoutLanguage);
+
+ TransformationConfiguration withLanguage = new TransformationConfiguration("config:foo:identifier:de", "",
+ "foo", "de", "content");
+ provider.add(withLanguage);
+
+ Mockito.verify(listenerMock).added(provider, withoutLanguage);
+ Mockito.verify(listenerMock).added(provider, withLanguage);
+ }
+
+ @Test
+ public void testValidConfigurationsIsUpdated() {
+ TransformationConfiguration configuration = new TransformationConfiguration("config:foo:identifier", "", "foo",
+ null, "content");
+ TransformationConfiguration updatedConfiguration = new TransformationConfiguration("config:foo:identifier", "",
+ "foo", null, "updated");
+
+ provider.add(configuration);
+ provider.update(updatedConfiguration);
+
+ Mockito.verify(listenerMock).added(provider, configuration);
+ Mockito.verify(listenerMock).updated(provider, configuration, updatedConfiguration);
+ }
+
+ @Test
+ public void testUidFormatValidation() {
+ TransformationConfiguration inValidUid = new TransformationConfiguration("invalid:foo:identifier", "", "foo",
+ null, "content");
+
+ assertThrows(IllegalArgumentException.class, () -> provider.add(inValidUid));
+ }
+
+ @Test
+ public void testLanguageValidations() {
+ TransformationConfiguration languageMissingInUid = new TransformationConfiguration("config:foo:identifier", "",
+ "foo", "de", "content");
+
+ assertThrows(IllegalArgumentException.class, () -> provider.add(languageMissingInUid));
+
+ TransformationConfiguration languageMissingInConfiguration = new TransformationConfiguration(
+ "config:foo:identifier:de", "", "foo", null, "content");
+
+ assertThrows(IllegalArgumentException.class, () -> provider.add(languageMissingInConfiguration));
+
+ TransformationConfiguration languageNotMatching = new TransformationConfiguration("config:foo:identifier:en",
+ "", "foo", "de", "content");
+
+ assertThrows(IllegalArgumentException.class, () -> provider.add(languageNotMatching));
+ }
+
+ @Test
+ public void testTypeValidation() {
+ TransformationConfiguration typeNotMatching = new TransformationConfiguration("config:foo:identifier", "",
+ "bar", null, "content");
+
+ assertThrows(IllegalArgumentException.class, () -> provider.add(typeNotMatching));
+ }
+
+ @Test
+ public void testSerializationDeserializationResultsInSameConfiguration() {
+ TransformationConfiguration configuration = new TransformationConfiguration("config:foo:identifier", "", "foo",
+ null, "content");
+ provider.add(configuration);
+
+ TransformationConfiguration configuration1 = provider.get("config:foo:identifier");
+
+ assertThat(configuration, is(configuration1));
+ }
+}
diff --git a/bundles/org.openhab.core.transform/src/test/java/org/openhab/core/transform/actions/TransformationTest.java b/bundles/org.openhab.core.transform/src/test/java/org/openhab/core/transform/actions/TransformationTest.java
index c5e02bce9d..beccca3217 100644
--- a/bundles/org.openhab.core.transform/src/test/java/org/openhab/core/transform/actions/TransformationTest.java
+++ b/bundles/org.openhab.core.transform/src/test/java/org/openhab/core/transform/actions/TransformationTest.java
@@ -12,7 +12,9 @@
*/
package org.openhab.core.transform.actions;
-import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
@@ -27,15 +29,13 @@ public class TransformationTest {
@Test
public void testTransform() {
String result = Transformation.transform("UnknownTransformation", "function", "test");
- assertEquals("test", result);
+ assertThat(result, is("test"));
}
@Test
public void testTransformRaw() {
- try {
- Transformation.transformRaw("UnknownTransformation", "function", "test");
- } catch (TransformationException e) {
- assertEquals("No transformation service 'UnknownTransformation' could be found.", e.getMessage());
- }
+ TransformationException e = assertThrows(TransformationException.class,
+ () -> Transformation.transformRaw("UnknownTransformation", "function", "test"));
+ assertThat(e.getMessage(), is("No transformation service 'UnknownTransformation' could be found."));
}
}
diff --git a/bundles/org.openhab.core.transform/src/test/java/org/openhab/core/transform/internal/TransformationConfigurationRegistryImplTest.java b/bundles/org.openhab.core.transform/src/test/java/org/openhab/core/transform/internal/TransformationConfigurationRegistryImplTest.java
new file mode 100644
index 0000000000..937458ad71
--- /dev/null
+++ b/bundles/org.openhab.core.transform/src/test/java/org/openhab/core/transform/internal/TransformationConfigurationRegistryImplTest.java
@@ -0,0 +1,114 @@
+/**
+ * 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.transform.internal;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+import java.util.Locale;
+
+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.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.transform.ManagedTransformationConfigurationProvider;
+import org.openhab.core.transform.TransformationConfiguration;
+
+/**
+ * The {@link TransformationConfigurationRegistryImplTest} includes tests for the
+ * {@link TransformationConfigurationRegistryImpl}
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class TransformationConfigurationRegistryImplTest {
+ private static final String SERVICE = "foo";
+
+ private static final String MANAGED_WITHOUT_LANGUAGE_UID = "config:" + SERVICE + ":managed";
+ private static final String MANAGED_WITH_EN_LANGUAGE_UID = "config:" + SERVICE + ":managed:en";
+ private static final String MANAGED_WITH_DE_LANGUAGE_UID = "config:" + SERVICE + ":managed:de";
+
+ private static final TransformationConfiguration MANAGED_WITHOUT_LANGUAGE = new TransformationConfiguration(
+ MANAGED_WITHOUT_LANGUAGE_UID, "", SERVICE, null, MANAGED_WITHOUT_LANGUAGE_UID);
+ private static final TransformationConfiguration MANAGED_WITH_EN_LANGUAGE = new TransformationConfiguration(
+ MANAGED_WITH_EN_LANGUAGE_UID, "", SERVICE, "en", MANAGED_WITH_EN_LANGUAGE_UID);
+ private static final TransformationConfiguration MANAGED_WITH_DE_LANGUAGE = new TransformationConfiguration(
+ MANAGED_WITH_DE_LANGUAGE_UID, "", SERVICE, "de", MANAGED_WITH_DE_LANGUAGE_UID);
+
+ private static final String FILE_WITHOUT_LANGUAGE_UID = "foo/FILE." + SERVICE;
+ private static final String FILE_WITH_EN_LANGUAGE_UID = "foo/FILE_en." + SERVICE;
+ private static final String FILE_WITH_DE_LANGUAGE_UID = "foo/FILE_de." + SERVICE;
+
+ private static final TransformationConfiguration FILE_WITHOUT_LANGUAGE = new TransformationConfiguration(
+ FILE_WITHOUT_LANGUAGE_UID, "", SERVICE, null, FILE_WITHOUT_LANGUAGE_UID);
+ private static final TransformationConfiguration FILE_WITH_EN_LANGUAGE = new TransformationConfiguration(
+ FILE_WITH_EN_LANGUAGE_UID, "", SERVICE, "en", FILE_WITH_EN_LANGUAGE_UID);
+ private static final TransformationConfiguration FILE_WITH_DE_LANGUAGE = new TransformationConfiguration(
+ FILE_WITH_DE_LANGUAGE_UID, "", SERVICE, "de", FILE_WITH_DE_LANGUAGE_UID);
+
+ private @Mock @NonNullByDefault({}) LocaleProvider localeProviderMock;
+
+ private @Mock @NonNullByDefault({}) ManagedTransformationConfigurationProvider providerMock;
+
+ private @NonNullByDefault({}) TransformationConfigurationRegistryImpl registry;
+
+ @BeforeEach
+ public void setup() {
+ Mockito.when(localeProviderMock.getLocale()).thenReturn(Locale.US);
+
+ registry = new TransformationConfigurationRegistryImpl(localeProviderMock);
+ registry.addProvider(providerMock);
+ registry.added(providerMock, MANAGED_WITHOUT_LANGUAGE);
+ registry.added(providerMock, MANAGED_WITH_EN_LANGUAGE);
+ registry.added(providerMock, MANAGED_WITH_DE_LANGUAGE);
+ registry.added(providerMock, FILE_WITHOUT_LANGUAGE);
+ registry.added(providerMock, FILE_WITH_EN_LANGUAGE);
+ registry.added(providerMock, FILE_WITH_DE_LANGUAGE);
+ }
+
+ @Test
+ public void testManagedReturnsCorrectLanguage() {
+ // language contained in uid, default requested (explicit uid takes precedence)
+ assertThat(registry.get(MANAGED_WITH_DE_LANGUAGE_UID, null), is(MANAGED_WITH_DE_LANGUAGE));
+ // language contained in uid, other requested (explicit uid takes precedence)
+ assertThat(registry.get(MANAGED_WITH_DE_LANGUAGE_UID, Locale.FRANCE), is(MANAGED_WITH_DE_LANGUAGE));
+ // no language in uid, default requested
+ assertThat(registry.get(MANAGED_WITHOUT_LANGUAGE_UID, null), is(MANAGED_WITH_EN_LANGUAGE));
+ // no language in uid, other requested
+ assertThat(registry.get(MANAGED_WITHOUT_LANGUAGE_UID, Locale.GERMANY), is(MANAGED_WITH_DE_LANGUAGE));
+ // no language in uid, unknown requested
+ assertThat(registry.get(MANAGED_WITHOUT_LANGUAGE_UID, Locale.FRANCE), is(MANAGED_WITHOUT_LANGUAGE));
+ }
+
+ @Test
+ public void testFileReturnsCorrectLanguage() {
+ // language contained in uid, default requested (explicit uid takes precedence)
+ assertThat(registry.get(FILE_WITH_DE_LANGUAGE_UID, null), is(FILE_WITH_DE_LANGUAGE));
+ // language contained in uid, other requested (explicit uid takes precedence)
+ assertThat(registry.get(FILE_WITH_DE_LANGUAGE_UID, Locale.FRANCE), is(FILE_WITH_DE_LANGUAGE));
+ // no language in uid, default requested
+ assertThat(registry.get(FILE_WITHOUT_LANGUAGE_UID, null), is(FILE_WITH_EN_LANGUAGE));
+ // no language in uid, other requested
+ assertThat(registry.get(FILE_WITHOUT_LANGUAGE_UID, Locale.GERMANY), is(FILE_WITH_DE_LANGUAGE));
+ // no language in uid, unknown requested
+ assertThat(registry.get(FILE_WITHOUT_LANGUAGE_UID, Locale.FRANCE), is(FILE_WITHOUT_LANGUAGE));
+ }
+}
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 776814bdb6..69fe3d2968 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -67,6 +67,7 @@
org.openhab.core.io.rest.sitemap
org.openhab.core.io.rest.sse
org.openhab.core.io.rest.swagger
+ org.openhab.core.io.rest.transform
org.openhab.core.io.rest.ui
org.openhab.core.io.rest.voice
org.openhab.core.io.transport.mdns
diff --git a/features/karaf/openhab-core/src/main/feature/feature.xml b/features/karaf/openhab-core/src/main/feature/feature.xml
index 727a8dae8e..2883d6b269 100644
--- a/features/karaf/openhab-core/src/main/feature/feature.xml
+++ b/features/karaf/openhab-core/src/main/feature/feature.xml
@@ -157,6 +157,11 @@
mvn:org.openhab.core.bundles/org.openhab.core.io.rest.audio/${project.version}
+
+ openhab-core-base
+ mvn:org.openhab.core.bundles/org.openhab.core.io.rest.transform/${project.version}
+
+
openhab-core-base
mvn:org.openhab.core.bundles/org.openhab.core.io.rest.voice/${project.version}
@@ -379,12 +384,13 @@
openhab-core-automation-module-media
openhab-core-io-console-karaf
openhab-core-io-http-auth
- openhab-core-io-rest-auth
- openhab-core-io-rest-sitemap
openhab-core-io-rest-audio
- openhab-core-io-rest-voice
- openhab-core-io-rest-swagger
+ openhab-core-io-rest-auth
openhab-core-io-rest-mdns
+ openhab-core-io-rest-sitemap
+ openhab-core-io-rest-swagger
+ openhab-core-io-rest-transform
+ openhab-core-io-rest-voice
openhab-core-model-lsp
openhab-core-model-item
openhab-core-model-persistence