From fe4cbe546e2ea4226b7f7cbc3f637223d33a5c12 Mon Sep 17 00:00:00 2001 From: J-N-K Date: Sun, 4 Feb 2024 10:29:09 +0100 Subject: [PATCH] Improve YAML model repository (#4024) Signed-off-by: Jan N. Klug --- .../core/model/yaml/AbstractYamlFile.java | 65 --- .../openhab/core/model/yaml/YamlElement.java | 40 +- .../core/model/yaml/YamlElementName.java | 28 ++ .../org/openhab/core/model/yaml/YamlFile.java | 48 -- .../core/model/yaml/YamlModelListener.java | 36 +- .../core/model/yaml/YamlModelRepository.java | 29 ++ .../core/model/yaml/YamlParseException.java | 39 -- .../yaml/internal/YamlModelRepository.java | 199 -------- .../internal/YamlModelRepositoryImpl.java | 428 ++++++++++++++++++ .../model/yaml/internal/YamlModelWrapper.java | 50 ++ ...manticTag.java => YamlSemanticTagDTO.java} | 15 +- .../semantics/YamlSemanticTagProvider.java | 38 +- .../internal/semantics/YamlSemanticTags.java | 36 -- .../internal/YamlModelRepositoryImplTest.java | 264 +++++++++++ .../core/model/yaml/test/FirstTypeDTO.java | 63 +++ .../core/model/yaml/test/SecondTypeDTO.java | 63 +++ .../resources/addToModelExpectedContent.yaml | 9 + .../resources/modelFileAddedOrRemoved.yaml | 28 ++ .../test/resources/modelFileUpdatePost.yaml | 11 + .../test/resources/modelFileUpdatePre.yaml | 11 + .../resources/modifyModelInitialContent.yaml | 7 + .../removeFromModelExpectedContent.yaml | 5 + .../updateInModelExpectedContent.yaml | 7 + .../checkstyle/suppressions.xml | 3 +- 24 files changed, 1071 insertions(+), 451 deletions(-) delete mode 100644 bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/AbstractYamlFile.java create mode 100644 bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlElementName.java delete mode 100644 bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlFile.java create mode 100644 bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlModelRepository.java delete mode 100644 bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlParseException.java delete mode 100644 bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepository.java create mode 100644 bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImpl.java create mode 100644 bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelWrapper.java rename bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/semantics/{YamlSemanticTag.java => YamlSemanticTagDTO.java} (78%) delete mode 100644 bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/semantics/YamlSemanticTags.java create mode 100644 bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImplTest.java create mode 100644 bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/test/FirstTypeDTO.java create mode 100644 bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/test/SecondTypeDTO.java create mode 100644 bundles/org.openhab.core.model.yaml/src/test/resources/addToModelExpectedContent.yaml create mode 100644 bundles/org.openhab.core.model.yaml/src/test/resources/modelFileAddedOrRemoved.yaml create mode 100644 bundles/org.openhab.core.model.yaml/src/test/resources/modelFileUpdatePost.yaml create mode 100644 bundles/org.openhab.core.model.yaml/src/test/resources/modelFileUpdatePre.yaml create mode 100644 bundles/org.openhab.core.model.yaml/src/test/resources/modifyModelInitialContent.yaml create mode 100644 bundles/org.openhab.core.model.yaml/src/test/resources/removeFromModelExpectedContent.yaml create mode 100644 bundles/org.openhab.core.model.yaml/src/test/resources/updateInModelExpectedContent.yaml diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/AbstractYamlFile.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/AbstractYamlFile.java deleted file mode 100644 index 9d6b4fedc..000000000 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/AbstractYamlFile.java +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright (c) 2010-2024 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.model.yaml; - -import java.util.List; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * The {@link AbstractYamlFile} is the DTO base class used to map a YAML configuration file. - * - * A YAML configuration file consists of a version and a list of elements. - * - * @author Laurent Garnier - Initial contribution - */ -@NonNullByDefault -public abstract class AbstractYamlFile implements YamlFile { - - private final Logger logger = LoggerFactory.getLogger(AbstractYamlFile.class); - - /** - * YAML file version - */ - public int version; - - @Override - public abstract List getElements(); - - @Override - public int getVersion() { - return version; - } - - @Override - public boolean isValid() { - // Checking duplicated elements - List elts = getElements(); - long nbDistinctIds = elts.stream().map(YamlElement::getId).distinct().count(); - if (nbDistinctIds < elts.size()) { - logger.debug("Elements with same ids detected in the file"); - return false; - } - - // Checking each element - for (int i = 0; i < elts.size(); i++) { - if (!elts.get(i).isValid()) { - logger.debug("Error in element {}", i + 1); - return false; - } - } - return true; - } -} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlElement.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlElement.java index 01f4e24a3..5eda852fb 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlElement.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlElement.java @@ -12,28 +12,54 @@ */ package org.openhab.core.model.yaml; -import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.NonNull; +import org.openhab.core.model.yaml.internal.YamlModelRepositoryImpl; /** - * The {@link YamlElement} interface offers an identifier and a check validity method - * to any element defined in a YAML configuration file. + * The {@link YamlElement} interface must be implemented by any classes that need to be handled by the + * {@link YamlModelRepositoryImpl}. + *

+ * Implementations + *

* * @author Laurent Garnier - Initial contribution + * @author Jan N. Klug - Refactoring and improvements to JavaDoc */ -@NonNullByDefault public interface YamlElement { /** - * Get the identifier of the YAML element + * Get the identifier of this element. + *

+ *

+ * Identifiers + *

* * @return the identifier as a string */ + @NonNull String getId(); /** - * Check that the YAML element is valid + * Check if this element is valid and should be included in the model. + *

+ *

+ * Implementations + *

* - * @return true if all the checks are OK + * @return {@code true} if all the checks are completed successfully */ boolean isValid(); } diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlElementName.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlElementName.java new file mode 100644 index 000000000..4e0080ebb --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlElementName.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2010-2024 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.model.yaml; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * The {@link YamlElementName} is a required annotation for the inheritors of {@link YamlElement}. It specifies the root + * element name in a YAML model that is described by the respective class. Code review MUST ensure that element names + * are unique. + * + * @author Jan N. Klug - Initial contribution + */ +@Retention(RetentionPolicy.RUNTIME) +public @interface YamlElementName { + String value(); +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlFile.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlFile.java deleted file mode 100644 index 7b7b17080..000000000 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlFile.java +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Copyright (c) 2010-2024 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.model.yaml; - -import java.util.List; - -import org.eclipse.jdt.annotation.NonNullByDefault; - -/** - * The {@link YamlFile} is the interface to manage the generic content of a YAML configuration file. - * - * @author Laurent Garnier - Initial contribution - */ -@NonNullByDefault -public interface YamlFile { - - /** - * Get the list of elements present in the YAML file. - * - * @return the list of elements - */ - List getElements(); - - /** - * Get the version present in the YAML file. - * - * @return the version in the file - */ - int getVersion(); - - /** - * Check that the file content is valid. - * It includes the check of duplicated elements (same identifier) and the check of each element. - * - * @return true if all the checks are OK - */ - boolean isValid(); -} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlModelListener.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlModelListener.java index 6d55131ca..3366ad5e0 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlModelListener.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlModelListener.java @@ -19,6 +19,9 @@ import org.eclipse.jdt.annotation.NonNullByDefault; /** * The {@link YamlModelListener} interface is responsible for managing a particular model type * with data processed from YAML configuration files. + *

+ * Implementors are notified whenever a YAML model changed that contains elements of the given type. They MUST declare + * at least {@link YamlModelListener} as service to automatically register them with the repository. * * @author Laurent Garnier - Initial contribution */ @@ -26,20 +29,23 @@ import org.eclipse.jdt.annotation.NonNullByDefault; public interface YamlModelListener { /** - * Method called by the model repository when elements from a model are added. + * Method called by the model repository when elements from a model are added. Only added elements are contained in + * the collection. In case the listener is added after a model was read, this method is also called with all + * elements from that model. * * @param modelName the name of the model * @param elements the collection of added elements */ - void addedModel(String modelName, Collection elements); + void addedModel(String modelName, Collection elements); /** - * Method called by the model repository when elements from a model are updated. + * Method called by the model repository when elements from a model are updated. Only changed elements are contained + * in the collection. * * @param modelName the name of the model * @param elements the collection of updated elements */ - void updatedModel(String modelName, Collection elements); + void updatedModel(String modelName, Collection elements); /** * Method called by the model repository when elements from a model are removed. @@ -47,27 +53,11 @@ public interface YamlModelListener { * @param modelName the name of the model * @param elements the collection of removed elements */ - void removedModel(String modelName, Collection elements); + void removedModel(String modelName, Collection elements); /** - * Get the root name of this model type which is also the name of the root folder - * containing the user files for this model type. - * - * A path is unexpected. What is expected is for example "items" or "things". - * - * @return the model root name - */ - String getRootName(); - - /** - * Get the DTO class to be used for a file providing objects for this model type. - * - * @return the DTO file class - */ - Class getFileClass(); - - /** - * Get the DTO class to be used for each object of this model type. + * Get the DTO class to be used for each object of this model type. The DTO class MUST implement {@link YamlElement} + * and fulfill all requirements defined for the interface. * * @return the DTO element class */ diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlModelRepository.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlModelRepository.java new file mode 100644 index 000000000..c4f074a24 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlModelRepository.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2010-2024 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.model.yaml; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link YamlModelRepository} defines methods to update elements in a YAML model. + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public interface YamlModelRepository { + void addElementToModel(String modelName, YamlElement element); + + void removeElementFromModel(String modelName, YamlElement element); + + void updateElementInModel(String modelName, YamlElement element); +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlParseException.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlParseException.java deleted file mode 100644 index 6d9c19ab7..000000000 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlParseException.java +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright (c) 2010-2024 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.model.yaml; - -import org.eclipse.jdt.annotation.NonNullByDefault; - -/** - * The {@link YamlParseException} is used when an error is detected when parsing the content - * of a YAML configuration file. - * - * @author Laurent Garnier - Initial contribution - */ -@NonNullByDefault -public class YamlParseException extends Exception { - - private static final long serialVersionUID = 1L; - - public YamlParseException(String message) { - super(message); - } - - public YamlParseException(Throwable cause) { - super(cause); - } - - public YamlParseException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepository.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepository.java deleted file mode 100644 index 30c1dc50b..000000000 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepository.java +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Copyright (c) 2010-2024 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.model.yaml.internal; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.core.model.yaml.AbstractYamlFile; -import org.openhab.core.model.yaml.YamlElement; -import org.openhab.core.model.yaml.YamlModelListener; -import org.openhab.core.model.yaml.YamlParseException; -import org.openhab.core.service.WatchService; -import org.openhab.core.service.WatchService.Kind; -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.osgi.service.component.annotations.ReferenceCardinality; -import org.osgi.service.component.annotations.ReferencePolicy; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; - -/** - * The {@link YamlModelRepository} is an OSGi service, that encapsulates all YAML file processing - * including file monitoring to detect created, updated and removed YAML configuration files. - * Data processed from these files are consumed by registered OSGi services that implement {@link YamlModelListener}. - * - * @author Laurent Garnier - Initial contribution - */ -@NonNullByDefault -@Component(immediate = true) -public class YamlModelRepository implements WatchService.WatchEventListener { - - private final Logger logger = LoggerFactory.getLogger(YamlModelRepository.class); - - private final WatchService watchService; - private final Path watchPath; - private final ObjectMapper yamlReader; - - private final Map>> listeners = new ConcurrentHashMap<>(); - private final Map> objects = new ConcurrentHashMap<>(); - - @Activate - public YamlModelRepository(@Reference(target = WatchService.CONFIG_WATCHER_FILTER) WatchService watchService) { - this.watchService = watchService; - this.yamlReader = new ObjectMapper(new YAMLFactory()); - yamlReader.findAndRegisterModules(); - - watchService.registerListener(this, Path.of("")); - watchPath = watchService.getWatchPath(); - } - - @Deactivate - public void deactivate() { - watchService.unregisterListener(this); - } - - // The method is "synchronized" to avoid concurrent files processing - @Override - public synchronized void processWatchEvent(Kind kind, Path path) { - Path fullPath = watchPath.resolve(path); - String dirName = path.subpath(0, 1).toString(); - - if (Files.isDirectory(fullPath) || fullPath.toFile().isHidden() || !fullPath.toString().endsWith(".yaml")) { - logger.trace("Ignored {}", fullPath); - return; - } - - getListeners(dirName).forEach(listener -> processWatchEvent(dirName, kind, fullPath, listener)); - } - - private void processWatchEvent(String dirName, Kind kind, Path fullPath, YamlModelListener listener) { - logger.debug("processWatchEvent dirName={} kind={} fullPath={} listener={}", dirName, kind, fullPath, - listener.getClass().getSimpleName()); - Map oldObjects; - Map newObjects; - if (kind == WatchService.Kind.DELETE) { - newObjects = Map.of(); - - List oldListObjects = objects.remove(fullPath); - if (oldListObjects == null) { - oldListObjects = List.of(); - } - oldObjects = oldListObjects.stream().collect(Collectors.toMap(YamlElement::getId, obj -> obj)); - } else { - AbstractYamlFile yamlData; - try { - yamlData = readYamlFile(fullPath, listener.getFileClass()); - } catch (YamlParseException e) { - logger.warn("Failed to parse Yaml file {} with DTO class {}: {}", fullPath, - listener.getFileClass().getName(), e.getMessage()); - return; - } - List newListObjects = yamlData.getElements(); - newObjects = newListObjects.stream().collect(Collectors.toMap(YamlElement::getId, obj -> obj)); - - List oldListObjects = objects.get(fullPath); - if (oldListObjects == null) { - oldListObjects = List.of(); - } - oldObjects = oldListObjects.stream().collect(Collectors.toMap(YamlElement::getId, obj -> obj)); - - objects.put(fullPath, newListObjects); - } - - String modelName = fullPath.toFile().getName(); - modelName = modelName.substring(0, modelName.indexOf(".yaml")); - List listElements; - listElements = oldObjects.entrySet().stream() - .filter(entry -> entry.getValue().getClass().equals(listener.getElementClass()) - && !newObjects.containsKey(entry.getKey())) - .map(Map.Entry::getValue).toList(); - if (!listElements.isEmpty()) { - listener.removedModel(modelName, listElements); - } - - listElements = newObjects.entrySet().stream() - .filter(entry -> entry.getValue().getClass().equals(listener.getElementClass()) - && !oldObjects.containsKey(entry.getKey())) - .map(Map.Entry::getValue).toList(); - if (!listElements.isEmpty()) { - listener.addedModel(modelName, listElements); - } - - // Object is ignored if unchanged - listElements = newObjects.entrySet().stream() - .filter(entry -> entry.getValue().getClass().equals(listener.getElementClass()) - && oldObjects.containsKey(entry.getKey()) - && !entry.getValue().equals(oldObjects.get(entry.getKey()))) - .map(Map.Entry::getValue).toList(); - if (!listElements.isEmpty()) { - listener.updatedModel(modelName, listElements); - } - } - - private AbstractYamlFile readYamlFile(Path path, Class dtoClass) - throws YamlParseException { - logger.info("Loading model '{}'", path.toFile().getName()); - logger.debug("readYamlFile {} with {}", path.toFile().getAbsolutePath(), dtoClass.getName()); - try { - AbstractYamlFile dto = yamlReader.readValue(path.toFile(), dtoClass); - if (!dto.isValid()) { - throw new YamlParseException("The file is not valid, some checks failed!"); - } - return dto; - } catch (IOException e) { - throw new YamlParseException(e); - } - } - - @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC) - protected void addYamlModelListener(YamlModelListener listener) { - String dirName = listener.getRootName(); - logger.debug("Adding model listener for {}", dirName); - getListeners(dirName).add(listener); - - // Load all existing YAML files - try (Stream stream = Files.walk(watchPath.resolve(dirName))) { - stream.forEach(path -> { - if (!Files.isDirectory(path) && !path.toFile().isHidden() && path.toString().endsWith(".yaml")) { - processWatchEvent(dirName, Kind.CREATE, path, listener); - } - }); - } catch (IOException ignored) { - } - } - - protected void removeYamlModelListener(YamlModelListener listener) { - String dirName = listener.getRootName(); - logger.debug("Removing model listener for {}", dirName); - getListeners(dirName).remove(listener); - } - - private List> getListeners(String dirName) { - return Objects.requireNonNull(listeners.computeIfAbsent(dirName, k -> new CopyOnWriteArrayList<>())); - } -} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImpl.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImpl.java new file mode 100644 index 000000000..228f2cf41 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImpl.java @@ -0,0 +1,428 @@ +/** + * Copyright (c) 2010-2024 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.model.yaml.internal; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.model.yaml.YamlElement; +import org.openhab.core.model.yaml.YamlElementName; +import org.openhab.core.model.yaml.YamlModelListener; +import org.openhab.core.model.yaml.YamlModelRepository; +import org.openhab.core.service.WatchService; +import org.openhab.core.service.WatchService.Kind; +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.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.component.annotations.ReferencePolicy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; + +/** + * The {@link YamlModelRepositoryImpl} is an OSGi service, that encapsulates all YAML file processing + * including file monitoring to detect created, updated and removed YAML configuration files. + * Data processed from these files are consumed by registered OSGi services that implement {@link YamlModelListener}. + * + * @author Laurent Garnier - Initial contribution + * @author Jan N. Klug - Refactored for multiple types per file and add modifying possibility + */ +@NonNullByDefault +@Component(immediate = true) +public class YamlModelRepositoryImpl implements WatchService.WatchEventListener, YamlModelRepository { + private static final int DEFAULT_MODEL_VERSION = 1; + private final Logger logger = LoggerFactory.getLogger(YamlModelRepositoryImpl.class); + + private final WatchService watchService; + private final Path watchPath; + private final ObjectMapper objectMapper; + + private final Map>> elementListeners = new ConcurrentHashMap<>(); + // all model nodes, ordered by model name (full path as string) and type + private final Map modelCache = new ConcurrentHashMap<>(); + + @Activate + public YamlModelRepositoryImpl(@Reference(target = WatchService.CONFIG_WATCHER_FILTER) WatchService watchService) { + YAMLFactory yamlFactory = YAMLFactory.builder() // + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) // omit "---" at file start + .disable(YAMLGenerator.Feature.SPLIT_LINES) // do not split long lines + .enable(YAMLGenerator.Feature.INDENT_ARRAYS_WITH_INDICATOR) // indent arrays + .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) // use quotes only where necessary + .build(); + this.objectMapper = new ObjectMapper(yamlFactory); + objectMapper.findAndRegisterModules(); + objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); + objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); + + this.watchService = watchService; + watchService.registerListener(this, Path.of("")); + watchPath = watchService.getWatchPath(); + } + + @Deactivate + public void deactivate() { + watchService.unregisterListener(this); + } + + // The method is "synchronized" to avoid concurrent files processing + @Override + @SuppressWarnings({ "rawtypes", "unchecked" }) + public synchronized void processWatchEvent(Kind kind, Path path) { + Path fullPath = watchPath.resolve(path); + String pathString = path.toString(); + if (Files.isDirectory(fullPath) || fullPath.toFile().isHidden() || !pathString.endsWith(".yaml")) { + logger.trace("Ignored {}", fullPath); + return; + } + + // strip extension for model name + String modelName = pathString.substring(0, pathString.lastIndexOf(".")); + + if (kind == WatchService.Kind.DELETE) { + logger.info("Removing YAML model {}", modelName); + YamlModelWrapper removedModel = modelCache.remove(modelName); + if (removedModel == null) { + return; + } + for (Map.Entry> modelEntry : removedModel.getNodes().entrySet()) { + String elementName = modelEntry.getKey(); + List removedNodes = modelEntry.getValue(); + if (!removedNodes.isEmpty()) { + getElementListeners(elementName).forEach(listener -> { + List removedElements = parseJsonNodes(removedNodes, listener.getElementClass()); + listener.removedModel(modelName, removedElements); + }); + } + } + } else { + if (kind == Kind.CREATE) { + logger.info("Adding YAML model {}", modelName); + } else { + logger.info("Updating YAML model {}", modelName); + } + try { + JsonNode fileContent = objectMapper.readTree(fullPath.toFile()); + + // check version + JsonNode versionNode = fileContent.get("version"); + if (versionNode == null || !versionNode.canConvertToInt()) { + logger.warn("Version is missing or not a number in model {}. Ignoring it.", modelName); + return; + } + int modelVersion = versionNode.asInt(); + if (modelVersion != DEFAULT_MODEL_VERSION) { + logger.warn("Model {} has version {}, but only version 1 is supported. Ignoring it.", modelName, + modelVersion); + return; + } + JsonNode readOnlyNode = fileContent.get("readOnly"); + boolean readOnly = readOnlyNode == null || readOnlyNode.asBoolean(false); + + YamlModelWrapper model = Objects.requireNonNull( + modelCache.computeIfAbsent(modelName, k -> new YamlModelWrapper(modelVersion, readOnly))); + + // get sub-elements + Iterator> it = fileContent.fields(); + while (it.hasNext()) { + Map.Entry element = it.next(); + String elementName = element.getKey(); + JsonNode node = element.getValue(); + if (!node.isArray()) { + // all processable sub-elements are arrays + logger.trace("Element {} in model {} is not an array, ignoring it", elementName, modelName); + continue; + } + + List oldNodeElements = model.getNodes().getOrDefault(elementName, List.of()); + List newNodeElements = new ArrayList<>(); + node.elements().forEachRemaining(newNodeElements::add); + + for (YamlModelListener elementListener : getElementListeners(elementName)) { + Class elementClass = elementListener.getElementClass(); + + Map oldElements = listToMap( + parseJsonNodes(oldNodeElements, elementClass)); + Map newElements = listToMap( + parseJsonNodes(newNodeElements, elementClass)); + + List addedElements = newElements.values().stream() + .filter(e -> !oldElements.containsKey(e.getId())).toList(); + List removedElements = oldElements.values().stream() + .filter(e -> !newElements.containsKey(e.getId())).toList(); + List updatedElements = newElements.values().stream().filter( + e -> oldElements.containsKey(e.getId()) && !e.equals(oldElements.get(e.getId()))) + .toList(); + + if (!addedElements.isEmpty()) { + elementListener.addedModel(modelName, addedElements); + } + if (!removedElements.isEmpty()) { + elementListener.removedModel(modelName, removedElements); + } + if (!updatedElements.isEmpty()) { + elementListener.updatedModel(modelName, updatedElements); + } + } + + // replace cache + model.getNodes().put(elementName, newNodeElements); + } + } catch (IOException e) { + logger.warn("Failed to read {}: {}", modelName, e.getMessage()); + } + } + } + + @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC) + @SuppressWarnings({ "rawtypes", "unchecked" }) + public void addYamlModelListener(YamlModelListener listener) { + Class elementClass = listener.getElementClass(); + YamlElementName annotation = elementClass.getAnnotation(YamlElementName.class); + if (annotation == null) { + logger.warn("Class {} is missing the mandatory YamlElementName annotation. This is a bug.", elementClass); + return; + } + String elementName = annotation.value(); + getElementListeners(elementName).add(listener); + + // iterate over all models and notify he new listener of already existing models with this type + for (Map.Entry model : modelCache.entrySet()) { + String modelName = model.getKey(); + List modelNodes = model.getValue().getNodes().get(elementName); + if (modelNodes == null || modelNodes.isEmpty()) { + continue; + } + List modelElements = parseJsonNodes(modelNodes, elementClass); + listener.addedModel(modelName, modelElements); + } + } + + public void removeYamlModelListener(YamlModelListener listener) { + elementListeners.values().forEach(list -> list.remove(listener)); + } + + @Override + @SuppressWarnings({ "rawtypes", "unchecked" }) + public void addElementToModel(String modelName, YamlElement element) { + YamlElementName annotation = element.getClass().getAnnotation(YamlElementName.class); + if (annotation == null) { + logger.warn( + "Failed to add element {}. Class {}) is missing the mandatory YamlElementName annotation. This is a bug.", + element.getId(), element.getClass()); + return; + } + String elementName = annotation.value(); + YamlModelWrapper model = Objects.requireNonNull( + modelCache.computeIfAbsent(modelName, k -> new YamlModelWrapper(DEFAULT_MODEL_VERSION, false))); + if (model.isReadOnly()) { + logger.warn("Modifying {} is not allowed, model is marked read-only", modelName); + return; + } + + List modelNodes = model.getNodes().computeIfAbsent(elementName, k -> new CopyOnWriteArrayList<>()); + JsonNode newNode = objectMapper.convertValue(element, JsonNode.class); + modelNodes.add(newNode); + // notify listeners + getElementListeners(elementName).forEach(l -> { + List newElements = parseJsonNodes(List.of(newNode), l.getElementClass()); + if (!newElements.isEmpty()) { + l.addedModel(modelName, newElements); + } + }); + + writeModel(modelName); + } + + @Override + @SuppressWarnings({ "rawtypes", "unchecked" }) + public void removeElementFromModel(String modelName, YamlElement element) { + YamlElementName annotation = element.getClass().getAnnotation(YamlElementName.class); + if (annotation == null) { + logger.warn( + "Failed to remove element {}. Class {}) is missing the mandatory YamlElementName annotation. This is a bug.", + element.getId(), element.getClass()); + return; + } + String elementName = annotation.value(); + YamlModelWrapper model = modelCache.get(modelName); + if (model == null) { + logger.warn("Failed to remove {} from model {} because the model is not known.", element, modelName); + return; + } + if (model.isReadOnly()) { + logger.warn("Modifying {} is not allowed, model is marked read-only", modelName); + return; + } + + List modelNodes = model.getNodes().get(elementName); + if (modelNodes == null) { + logger.warn("Failed to remove {} from model {} because type {} is not known in the model.", element, + modelName, elementName); + return; + } + JsonNode toRemove = findNodeById(modelNodes, element.getClass(), element.getId()); + if (toRemove == null) { + logger.warn("Failed to remove {} from model {} because element is not in model.", element, modelName); + return; + } + modelNodes.remove(toRemove); + // notify listeners + getElementListeners(elementName).forEach(l -> { + List newElements = parseJsonNodes(List.of(toRemove), l.getElementClass()); + if (!newElements.isEmpty()) { + l.addedModel(modelName, newElements); + } + }); + + writeModel(modelName); + } + + @Override + @SuppressWarnings({ "rawtypes", "unchecked" }) + public void updateElementInModel(String modelName, YamlElement element) { + YamlElementName annotation = element.getClass().getAnnotation(YamlElementName.class); + if (annotation == null) { + logger.warn( + "Failed to update element {}. Class {}) is missing the mandatory YamlElementName annotation. This is a bug.", + element.getId(), element.getClass()); + return; + } + String elementName = annotation.value(); + YamlModelWrapper model = modelCache.get(modelName); + if (model == null) { + logger.warn("Failed to update {} in model {} because the model is not known.", element, modelName); + return; + } + if (model.isReadOnly()) { + logger.warn("Modifying {} is not allowed, model is marked read-only", modelName); + return; + } + + List modelNodes = model.getNodes().get(elementName); + if (modelNodes == null) { + logger.warn("Failed to update {} in model {} because type {} is not known in the model.", element, + modelName, elementName); + return; + } + JsonNode oldElement = findNodeById(modelNodes, element.getClass(), element.getId()); + if (oldElement == null) { + logger.warn("Failed to update {} in model {} because element is not in model.", element, modelName); + return; + } + + JsonNode newNode = objectMapper.convertValue(element, JsonNode.class); + modelNodes.set(modelNodes.indexOf(oldElement), newNode); + // notify listeners + getElementListeners(elementName).forEach(l -> { + List newElements = parseJsonNodes(List.of(newNode), l.getElementClass()); + if (!newElements.isEmpty()) { + l.updatedModel(modelName, newElements); + } + }); + + writeModel(modelName); + } + + private void writeModel(String modelName) { + YamlModelWrapper model = modelCache.get(modelName); + if (model == null) { + logger.warn("Failed to write model {} to disk because it is not known.", modelName); + return; + } + + if (model.isReadOnly()) { + logger.warn("Failed to write model {} to disk because it is marked as read-only.", modelName); + return; + } + + // create the model + JsonNodeFactory nodeFactory = objectMapper.getNodeFactory(); + ObjectNode rootNode = nodeFactory.objectNode(); + + rootNode.put("version", model.getVersion()); + rootNode.put("readOnly", model.isReadOnly()); + for (Map.Entry> elementNodes : model.getNodes().entrySet()) { + ArrayNode arrayNode = nodeFactory.arrayNode(); + elementNodes.getValue().forEach(arrayNode::add); + rootNode.set(elementNodes.getKey(), arrayNode); + } + + try { + Path outFile = watchPath.resolve(modelName + ".yaml"); + String fileContent = objectMapper.writeValueAsString(rootNode); + if (Files.exists(outFile) && !Files.isWritable(outFile)) { + logger.warn("Failed writing model {}: model exists but is read-only.", modelName); + return; + } + Files.writeString(outFile, fileContent); + } catch (JsonProcessingException e) { + logger.warn("Failed to serialize model {}: {}", modelName, e.getMessage()); + } catch (IOException e) { + logger.warn("Failed writing model {}: {}", modelName, e.getMessage()); + } + } + + private List> getElementListeners(String elementName) { + return Objects.requireNonNull(elementListeners.computeIfAbsent(elementName, k -> new CopyOnWriteArrayList<>())); + } + + private @Nullable JsonNode findNodeById(List nodes, Class elementClass, + String id) { + return nodes.stream().filter(node -> { + Optional parsedNode = parseJsonNode(node, elementClass); + return parsedNode.filter(yamlDTO -> id.equals(yamlDTO.getId())).isPresent(); + }).findAny().orElse(null); + } + + private Map listToMap(List elements) { + return elements.stream().collect(Collectors.toMap(YamlElement::getId, e -> e)); + } + + private List parseJsonNodes(List nodes, Class elementClass) { + return nodes.stream().map(nE -> parseJsonNode(nE, elementClass)).filter(Optional::isPresent).map(Optional::get) + .filter(YamlElement::isValid).toList(); + } + + private Optional parseJsonNode(JsonNode node, Class elementClass) { + try { + return Optional.of(objectMapper.treeToValue(node, elementClass)); + } catch (JsonProcessingException e) { + logger.warn("Could not parse element {} to {}: {}", node, elementClass, e.getMessage()); + return Optional.empty(); + } + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelWrapper.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelWrapper.java new file mode 100644 index 000000000..d38defea9 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelWrapper.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2010-2024 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.model.yaml.internal; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * The {@link YamlModelWrapper} is used to store the information read from a model in the model cache. + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public class YamlModelWrapper { + private final int version; + private final boolean readOnly; + private final Map> nodes = new ConcurrentHashMap<>(); + + public YamlModelWrapper(int version, boolean readOnly) { + this.version = version; + this.readOnly = readOnly; + } + + public int getVersion() { + return version; + } + + public boolean isReadOnly() { + return readOnly; + } + + public Map> getNodes() { + return nodes; + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/semantics/YamlSemanticTag.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/semantics/YamlSemanticTagDTO.java similarity index 78% rename from bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/semantics/YamlSemanticTag.java rename to bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/semantics/YamlSemanticTagDTO.java index dd6c07362..213a444dc 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/semantics/YamlSemanticTag.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/semantics/YamlSemanticTagDTO.java @@ -15,31 +15,34 @@ package org.openhab.core.model.yaml.internal.semantics; import java.util.List; import java.util.Objects; +import org.eclipse.jdt.annotation.NonNull; import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.model.yaml.YamlElement; +import org.openhab.core.model.yaml.YamlElementName; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * The {@link YamlSemanticTag} is a data transfer object used to serialize a semantic tag + * The {@link YamlSemanticTagDTO} is a data transfer object used to serialize a semantic tag * in a YAML configuration file. * * @author Laurent Garnier - Initial contribution */ -public class YamlSemanticTag implements YamlElement { +@YamlElementName("tags") +public class YamlSemanticTagDTO implements YamlElement { - private final Logger logger = LoggerFactory.getLogger(YamlSemanticTag.class); + private final Logger logger = LoggerFactory.getLogger(YamlSemanticTagDTO.class); public String uid; public String label; public String description; public List synonyms; - public YamlSemanticTag() { + public YamlSemanticTagDTO() { } @Override - public String getId() { + public @NonNull String getId() { return uid; } @@ -59,7 +62,7 @@ public class YamlSemanticTag implements YamlElement { } else if (obj == null || getClass() != obj.getClass()) { return false; } - YamlSemanticTag that = (YamlSemanticTag) obj; + YamlSemanticTagDTO that = (YamlSemanticTagDTO) obj; return Objects.equals(uid, that.uid) && Objects.equals(label, that.label) && Objects.equals(description, that.description) && Objects.equals(synonyms, that.synonyms); } diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/semantics/YamlSemanticTagProvider.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/semantics/YamlSemanticTagProvider.java index 9c6d29179..ef8b62dc4 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/semantics/YamlSemanticTagProvider.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/semantics/YamlSemanticTagProvider.java @@ -20,13 +20,10 @@ import java.util.TreeSet; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.common.registry.AbstractProvider; -import org.openhab.core.model.yaml.AbstractYamlFile; -import org.openhab.core.model.yaml.YamlElement; import org.openhab.core.model.yaml.YamlModelListener; import org.openhab.core.semantics.SemanticTag; import org.openhab.core.semantics.SemanticTagImpl; import org.openhab.core.semantics.SemanticTagProvider; -import org.openhab.core.semantics.SemanticTagRegistry; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Deactivate; @@ -37,7 +34,7 @@ import org.slf4j.LoggerFactory; * {@link YamlSemanticTagProvider} is an OSGi service, that allows to define semantic tags * in YAML configuration files in folder conf/tags. * Files can be added, updated or removed at runtime. - * These semantic tags are automatically exposed to the {@link SemanticTagRegistry}. + * These semantic tags are automatically exposed to the {@link org.openhab.core.semantics.SemanticTagRegistry}. * * @author Laurent Garnier - Initial contribution */ @@ -45,7 +42,7 @@ import org.slf4j.LoggerFactory; @Component(immediate = true, service = { SemanticTagProvider.class, YamlSemanticTagProvider.class, YamlModelListener.class }) public class YamlSemanticTagProvider extends AbstractProvider - implements SemanticTagProvider, YamlModelListener { + implements SemanticTagProvider, YamlModelListener { private final Logger logger = LoggerFactory.getLogger(YamlSemanticTagProvider.class); @@ -66,23 +63,13 @@ public class YamlSemanticTagProvider extends AbstractProvider } @Override - public String getRootName() { - return "tags"; + public Class getElementClass() { + return YamlSemanticTagDTO.class; } @Override - public Class getFileClass() { - return YamlSemanticTags.class; - } - - @Override - public Class getElementClass() { - return YamlSemanticTag.class; - } - - @Override - public void addedModel(String modelName, Collection elements) { - List added = elements.stream().map(e -> mapSemanticTag((YamlSemanticTag) e)) + public void addedModel(String modelName, Collection elements) { + List added = elements.stream().map(this::mapSemanticTag) .sorted(Comparator.comparing(SemanticTag::getUID)).toList(); tags.addAll(added); added.forEach(t -> { @@ -92,8 +79,8 @@ public class YamlSemanticTagProvider extends AbstractProvider } @Override - public void updatedModel(String modelName, Collection elements) { - List updated = elements.stream().map(e -> mapSemanticTag((YamlSemanticTag) e)).toList(); + public void updatedModel(String modelName, Collection elements) { + List updated = elements.stream().map(this::mapSemanticTag).toList(); updated.forEach(t -> { tags.stream().filter(tag -> tag.getUID().equals(t.getUID())).findFirst().ifPresentOrElse(oldTag -> { tags.remove(oldTag); @@ -105,8 +92,8 @@ public class YamlSemanticTagProvider extends AbstractProvider } @Override - public void removedModel(String modelName, Collection elements) { - List removed = elements.stream().map(e -> mapSemanticTag((YamlSemanticTag) e)) + public void removedModel(String modelName, Collection elements) { + List removed = elements.stream().map(this::mapSemanticTag) .sorted(Comparator.comparing(SemanticTag::getUID).reversed()).toList(); removed.forEach(t -> { tags.stream().filter(tag -> tag.getUID().equals(t.getUID())).findFirst().ifPresentOrElse(oldTag -> { @@ -117,10 +104,7 @@ public class YamlSemanticTagProvider extends AbstractProvider }); } - private SemanticTag mapSemanticTag(YamlSemanticTag tagDTO) { - if (tagDTO.uid == null) { - throw new IllegalArgumentException("The argument 'tagDTO.uid' must not be null."); - } + private SemanticTag mapSemanticTag(YamlSemanticTagDTO tagDTO) { return new SemanticTagImpl(tagDTO.uid, tagDTO.label, tagDTO.description, tagDTO.synonyms); } } diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/semantics/YamlSemanticTags.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/semantics/YamlSemanticTags.java deleted file mode 100644 index c18798c68..000000000 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/semantics/YamlSemanticTags.java +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright (c) 2010-2024 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.model.yaml.internal.semantics; - -import java.util.List; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.core.model.yaml.AbstractYamlFile; -import org.openhab.core.model.yaml.YamlElement; - -/** - * The {@link YamlSemanticTags} is a data transfer object used to serialize a list of semantic tags - * in a YAML configuration file. - * - * @author Laurent Garnier - Initial contribution - */ -@NonNullByDefault -public class YamlSemanticTags extends AbstractYamlFile { - - public List tags = List.of(); - - @Override - public List getElements() { - return tags; - } -} diff --git a/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImplTest.java b/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImplTest.java new file mode 100644 index 000000000..65b9368dc --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImplTest.java @@ -0,0 +1,264 @@ +/** + * Copyright (c) 2010-2024 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.model.yaml.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Collection; +import java.util.List; + +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.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +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.model.yaml.YamlElement; +import org.openhab.core.model.yaml.YamlModelListener; +import org.openhab.core.model.yaml.test.FirstTypeDTO; +import org.openhab.core.model.yaml.test.SecondTypeDTO; +import org.openhab.core.service.WatchService; + +/** + * The {@link YamlModelRepositoryImplTest} contains tests for the {@link YamlModelRepositoryImpl} class. + * + * @author Jan N. Klug - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@NonNullByDefault +public class YamlModelRepositoryImplTest { + private static final Path SOURCE_PATH = Path.of("src/test/resources"); + private static final String MODEL_NAME = "model"; + private static final Path MODEL_PATH = Path.of(MODEL_NAME + ".yaml"); + + private @Mock @NonNullByDefault({}) WatchService watchServiceMock; + private @TempDir @NonNullByDefault({}) Path watchPath; + private @NonNullByDefault({}) Path fullModelPath; + + private @Mock @NonNullByDefault({}) YamlModelListener<@NonNull FirstTypeDTO> firstTypeListener; + private @Mock @NonNullByDefault({}) YamlModelListener<@NonNull SecondTypeDTO> secondTypeListener1; + private @Mock @NonNullByDefault({}) YamlModelListener<@NonNull SecondTypeDTO> secondTypeListener2; + + private @Captor @NonNullByDefault({}) ArgumentCaptor> firstTypeCaptor; + private @Captor @NonNullByDefault({}) ArgumentCaptor> secondTypeCaptor1; + private @Captor @NonNullByDefault({}) ArgumentCaptor> secondTypeCaptor2; + + @BeforeEach + public void setup() { + fullModelPath = watchPath.resolve(MODEL_PATH); + when(watchServiceMock.getWatchPath()).thenReturn(watchPath); + + when(firstTypeListener.getElementClass()).thenReturn(FirstTypeDTO.class); + when(secondTypeListener1.getElementClass()).thenReturn(SecondTypeDTO.class); + when(secondTypeListener2.getElementClass()).thenReturn(SecondTypeDTO.class); + } + + @Test + public void testFileAddedAfterListeners() throws IOException { + Files.copy(SOURCE_PATH.resolve("modelFileAddedOrRemoved.yaml"), fullModelPath); + + YamlModelRepositoryImpl modelRepository = new YamlModelRepositoryImpl(watchServiceMock); + modelRepository.addYamlModelListener(firstTypeListener); + modelRepository.addYamlModelListener(secondTypeListener1); + modelRepository.addYamlModelListener(secondTypeListener2); + + modelRepository.processWatchEvent(WatchService.Kind.CREATE, MODEL_PATH); + + verify(firstTypeListener).addedModel(eq(MODEL_NAME), firstTypeCaptor.capture()); + verify(firstTypeListener, never()).updatedModel(any(), any()); + verify(firstTypeListener, never()).removedModel(any(), any()); + verify(secondTypeListener1).addedModel(eq(MODEL_NAME), secondTypeCaptor1.capture()); + verify(secondTypeListener1, never()).updatedModel(any(), any()); + verify(secondTypeListener1, never()).removedModel(any(), any()); + verify(secondTypeListener2).addedModel(eq(MODEL_NAME), secondTypeCaptor2.capture()); + verify(secondTypeListener2, never()).updatedModel(any(), any()); + verify(secondTypeListener2, never()).removedModel(any(), any()); + + Collection firstTypeElements = firstTypeCaptor.getValue(); + Collection secondTypeElements1 = secondTypeCaptor1.getValue(); + Collection secondTypeElements2 = secondTypeCaptor2.getValue(); + + assertThat(firstTypeElements, hasSize(2)); + assertThat(secondTypeElements1, hasSize(1)); + assertThat(secondTypeElements2, hasSize(1)); + } + + @Test + public void testFileAddedBeforeListeners() throws IOException { + Files.copy(SOURCE_PATH.resolve("modelFileAddedOrRemoved.yaml"), fullModelPath); + YamlModelRepositoryImpl modelRepository = new YamlModelRepositoryImpl(watchServiceMock); + + modelRepository.processWatchEvent(WatchService.Kind.CREATE, MODEL_PATH); + + modelRepository.addYamlModelListener(firstTypeListener); + modelRepository.addYamlModelListener(secondTypeListener1); + modelRepository.addYamlModelListener(secondTypeListener2); + + verify(firstTypeListener).addedModel(eq(MODEL_NAME), firstTypeCaptor.capture()); + verify(firstTypeListener, never()).updatedModel(any(), any()); + verify(firstTypeListener, never()).removedModel(any(), any()); + verify(secondTypeListener1).addedModel(eq(MODEL_NAME), secondTypeCaptor1.capture()); + verify(secondTypeListener1, never()).updatedModel(any(), any()); + verify(secondTypeListener1, never()).removedModel(any(), any()); + verify(secondTypeListener2).addedModel(eq(MODEL_NAME), secondTypeCaptor2.capture()); + verify(secondTypeListener2, never()).updatedModel(any(), any()); + verify(secondTypeListener2, never()).removedModel(any(), any()); + + Collection firstTypeElements = firstTypeCaptor.getValue(); + Collection secondTypeElements1 = secondTypeCaptor1.getValue(); + Collection secondTypeElements2 = secondTypeCaptor2.getValue(); + + assertThat(firstTypeElements, + containsInAnyOrder(new FirstTypeDTO("First1", "Description1"), new FirstTypeDTO("First2", null))); + assertThat(secondTypeElements1, contains(new SecondTypeDTO("Second1", "Label1"))); + assertThat(secondTypeElements1, contains(new SecondTypeDTO("Second1", "Label1"))); + } + + @Test + public void testFileUpdated() throws IOException { + YamlModelRepositoryImpl modelRepository = new YamlModelRepositoryImpl(watchServiceMock); + modelRepository.addYamlModelListener(firstTypeListener); + + Files.copy(SOURCE_PATH.resolve("modelFileUpdatePost.yaml"), fullModelPath); + modelRepository.processWatchEvent(WatchService.Kind.CREATE, MODEL_PATH); + verify(firstTypeListener).addedModel(eq(MODEL_NAME), any()); + + Files.copy(SOURCE_PATH.resolve("modelFileUpdatePre.yaml"), fullModelPath, StandardCopyOption.REPLACE_EXISTING); + modelRepository.processWatchEvent(WatchService.Kind.MODIFY, MODEL_PATH); + verify(firstTypeListener, times(2)).addedModel(eq(MODEL_NAME), firstTypeCaptor.capture()); + verify(firstTypeListener).updatedModel(eq(MODEL_NAME), firstTypeCaptor.capture()); + verify(firstTypeListener).removedModel(eq(MODEL_NAME), firstTypeCaptor.capture()); + + List> arguments = firstTypeCaptor.getAllValues(); + assertThat(arguments, hasSize(4)); + + // added originally + assertThat(arguments.get(0), containsInAnyOrder(new FirstTypeDTO("First", "First original"), + new FirstTypeDTO("Second", "Second original"), new FirstTypeDTO("Third", "Third original"))); + // added by update + assertThat(arguments.get(1), contains(new FirstTypeDTO("Fourth", "Fourth original"))); + // updated by update + assertThat(arguments.get(2), contains(new FirstTypeDTO("Second", "Second modified"))); + // removed by update + assertThat(arguments.get(3), contains(new FirstTypeDTO("Third", "Third original"))); + } + + @Test + public void testFileRemoved() throws IOException { + YamlModelRepositoryImpl modelRepository = new YamlModelRepositoryImpl(watchServiceMock); + + modelRepository.addYamlModelListener(firstTypeListener); + + Files.copy(SOURCE_PATH.resolve("modelFileUpdatePost.yaml"), fullModelPath); + modelRepository.processWatchEvent(WatchService.Kind.CREATE, MODEL_PATH); + modelRepository.processWatchEvent(WatchService.Kind.DELETE, MODEL_PATH); + + verify(firstTypeListener).addedModel(eq(MODEL_NAME), firstTypeCaptor.capture()); + verify(firstTypeListener, never()).updatedModel(any(), any()); + verify(firstTypeListener).removedModel(eq(MODEL_NAME), firstTypeCaptor.capture()); + + List> arguments = firstTypeCaptor.getAllValues(); + assertThat(arguments, hasSize(2)); + + // all are added + assertThat(arguments.get(0), containsInAnyOrder(new FirstTypeDTO("First", "First original"), + new FirstTypeDTO("Second", "Second original"), new FirstTypeDTO("Third", "Third original"))); + // all are removed + assertThat(arguments.get(0), containsInAnyOrder(new FirstTypeDTO("First", "First original"), + new FirstTypeDTO("Second", "Second original"), new FirstTypeDTO("Third", "Third original"))); + } + + @Test + public void testAddElementToModel() throws IOException { + Files.copy(SOURCE_PATH.resolve("modifyModelInitialContent.yaml"), fullModelPath); + + YamlModelRepositoryImpl modelRepository = new YamlModelRepositoryImpl(watchServiceMock); + modelRepository.processWatchEvent(WatchService.Kind.CREATE, MODEL_PATH); + + FirstTypeDTO added = new FirstTypeDTO("element3", "description3"); + modelRepository.addElementToModel(MODEL_NAME, added); + + String actualFileContent = Files.readString(fullModelPath); + String expectedFileContent = Files.readString(SOURCE_PATH.resolve("addToModelExpectedContent.yaml")); + + assertThat(actualFileContent, is(expectedFileContent)); + } + + @Test + public void testUpdateElementInModel() throws IOException { + Files.copy(SOURCE_PATH.resolve("modifyModelInitialContent.yaml"), fullModelPath); + + YamlModelRepositoryImpl modelRepository = new YamlModelRepositoryImpl(watchServiceMock); + modelRepository.processWatchEvent(WatchService.Kind.CREATE, MODEL_PATH); + + FirstTypeDTO updated = new FirstTypeDTO("element1", "newDescription1"); + modelRepository.updateElementInModel(MODEL_NAME, updated); + + String actualFileContent = Files.readString(fullModelPath); + String expectedFileContent = Files.readString(SOURCE_PATH.resolve("updateInModelExpectedContent.yaml")); + + assertThat(actualFileContent, is(expectedFileContent)); + } + + @Test + public void testRemoveElementFromModel() throws IOException { + Files.copy(SOURCE_PATH.resolve("modifyModelInitialContent.yaml"), fullModelPath); + + YamlModelRepositoryImpl modelRepository = new YamlModelRepositoryImpl(watchServiceMock); + modelRepository.processWatchEvent(WatchService.Kind.CREATE, MODEL_PATH); + + FirstTypeDTO removed = new FirstTypeDTO("element1", "description1"); + modelRepository.removeElementFromModel(MODEL_NAME, removed); + + String actualFileContent = Files.readString(fullModelPath); + String expectedFileContent = Files.readString(SOURCE_PATH.resolve("removeFromModelExpectedContent.yaml")); + + assertThat(actualFileContent, is(expectedFileContent)); + } + + @Test + public void testReadOnlyModelNotUpdated() throws IOException { + Files.copy(SOURCE_PATH.resolve("modelFileAddedOrRemoved.yaml"), fullModelPath); + + YamlModelRepositoryImpl modelRepository = new YamlModelRepositoryImpl(watchServiceMock); + modelRepository.processWatchEvent(WatchService.Kind.CREATE, MODEL_PATH); + + FirstTypeDTO added = new FirstTypeDTO("element3", "description3"); + modelRepository.addElementToModel(MODEL_NAME, added); + + FirstTypeDTO removed = new FirstTypeDTO("element1", "description1"); + modelRepository.removeElementFromModel(MODEL_NAME, removed); + + FirstTypeDTO updated = new FirstTypeDTO("element2", "newDescription2"); + modelRepository.updateElementInModel(MODEL_NAME, updated); + + String actualFileContent = Files.readString(fullModelPath); + String expectedFileContent = Files.readString(SOURCE_PATH.resolve("modelFileAddedOrRemoved.yaml")); + + assertThat(actualFileContent, is(expectedFileContent)); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/test/FirstTypeDTO.java b/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/test/FirstTypeDTO.java new file mode 100644 index 000000000..736effc0f --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/test/FirstTypeDTO.java @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2010-2024 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.model.yaml.test; + +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNull; +import org.openhab.core.model.yaml.YamlElement; +import org.openhab.core.model.yaml.YamlElementName; + +/** + * The {@link FirstTypeDTO} is a test type implementing {@link YamlElement} + * + * @author Jan N. Klug - Initial contribution + */ +@YamlElementName("firstType") +public class FirstTypeDTO implements YamlElement { + public String uid; + public String description; + + public FirstTypeDTO() { + } + + public FirstTypeDTO(String uid, String description) { + this.uid = uid; + this.description = description; + } + + @Override + public @NonNull String getId() { + return uid; + } + + @Override + public boolean isValid() { + return uid != null; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + FirstTypeDTO that = (FirstTypeDTO) o; + return Objects.equals(uid, that.uid) && Objects.equals(description, that.description); + } + + @Override + public int hashCode() { + return Objects.hash(uid, description); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/test/SecondTypeDTO.java b/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/test/SecondTypeDTO.java new file mode 100644 index 000000000..200980995 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/test/SecondTypeDTO.java @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2010-2024 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.model.yaml.test; + +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNull; +import org.openhab.core.model.yaml.YamlElement; +import org.openhab.core.model.yaml.YamlElementName; + +/** + * The {@link SecondTypeDTO} is a test type implementing {@link YamlElement} + * + * @author Jan N. Klug - Initial contribution + */ +@YamlElementName("secondType") +public class SecondTypeDTO implements YamlElement { + public String id; + public String label; + + public SecondTypeDTO() { + } + + public SecondTypeDTO(String id, String label) { + this.id = id; + this.label = label; + } + + @Override + public @NonNull String getId() { + return id; + } + + @Override + public boolean isValid() { + return id != null; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + SecondTypeDTO that = (SecondTypeDTO) o; + return Objects.equals(id, that.id) && Objects.equals(label, that.label); + } + + @Override + public int hashCode() { + return Objects.hash(id, label); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/test/resources/addToModelExpectedContent.yaml b/bundles/org.openhab.core.model.yaml/src/test/resources/addToModelExpectedContent.yaml new file mode 100644 index 000000000..1fbe75daf --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/test/resources/addToModelExpectedContent.yaml @@ -0,0 +1,9 @@ +version: 1 +readOnly: false +firstType: + - uid: element1 + description: description1 + - uid: element2 + description: description2 + - uid: element3 + description: description3 diff --git a/bundles/org.openhab.core.model.yaml/src/test/resources/modelFileAddedOrRemoved.yaml b/bundles/org.openhab.core.model.yaml/src/test/resources/modelFileAddedOrRemoved.yaml new file mode 100644 index 000000000..2628f03bd --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/test/resources/modelFileAddedOrRemoved.yaml @@ -0,0 +1,28 @@ +# A YAML test file with different types +# The structure is valid but also contains invalid elements + +version: 1 + +# known first type +firstType: + # a valid element with uid and description + - uid: First1 + description: Description1 + + # a valid element with uid only + - uid: First2 + + # an invalid element (missing uid) + - description: Description3 + +# known second type +secondType: + # a valid element with id and label + - id: Second1 + label: Label1 + +# unknown third type +thirdType: + - foo: Bar + +nonArrayElement: Test diff --git a/bundles/org.openhab.core.model.yaml/src/test/resources/modelFileUpdatePost.yaml b/bundles/org.openhab.core.model.yaml/src/test/resources/modelFileUpdatePost.yaml new file mode 100644 index 000000000..4648c250e --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/test/resources/modelFileUpdatePost.yaml @@ -0,0 +1,11 @@ +# A YAML test file for updating the model + +version: 1 + +firstType: + - uid: First + description: First original + - uid: Second + description: Second original + - uid: Third + description: Third original diff --git a/bundles/org.openhab.core.model.yaml/src/test/resources/modelFileUpdatePre.yaml b/bundles/org.openhab.core.model.yaml/src/test/resources/modelFileUpdatePre.yaml new file mode 100644 index 000000000..b183f1206 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/test/resources/modelFileUpdatePre.yaml @@ -0,0 +1,11 @@ +# A YAML test file for updating the model + +version: 1 + +firstType: + - uid: First + description: First original + - uid: Second + description: Second modified + - uid: Fourth + description: Fourth original diff --git a/bundles/org.openhab.core.model.yaml/src/test/resources/modifyModelInitialContent.yaml b/bundles/org.openhab.core.model.yaml/src/test/resources/modifyModelInitialContent.yaml new file mode 100644 index 000000000..9f3b85b89 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/test/resources/modifyModelInitialContent.yaml @@ -0,0 +1,7 @@ +version: 1 +readOnly: false +firstType: + - uid: element1 + description: description1 + - uid: element2 + description: description2 diff --git a/bundles/org.openhab.core.model.yaml/src/test/resources/removeFromModelExpectedContent.yaml b/bundles/org.openhab.core.model.yaml/src/test/resources/removeFromModelExpectedContent.yaml new file mode 100644 index 000000000..82d98b0b4 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/test/resources/removeFromModelExpectedContent.yaml @@ -0,0 +1,5 @@ +version: 1 +readOnly: false +firstType: + - uid: element2 + description: description2 diff --git a/bundles/org.openhab.core.model.yaml/src/test/resources/updateInModelExpectedContent.yaml b/bundles/org.openhab.core.model.yaml/src/test/resources/updateInModelExpectedContent.yaml new file mode 100644 index 000000000..bcbf1240a --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/test/resources/updateInModelExpectedContent.yaml @@ -0,0 +1,7 @@ +version: 1 +readOnly: false +firstType: + - uid: element1 + description: newDescription1 + - uid: element2 + description: description2 diff --git a/tools/static-code-analysis/checkstyle/suppressions.xml b/tools/static-code-analysis/checkstyle/suppressions.xml index f191a031a..44d3fd507 100644 --- a/tools/static-code-analysis/checkstyle/suppressions.xml +++ b/tools/static-code-analysis/checkstyle/suppressions.xml @@ -5,13 +5,14 @@ + - +