You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@unomi.apache.org by sh...@apache.org on 2026/05/05 15:23:20 UTC
(unomi) branch UNOMI-920-yaml-tostring created (now 3ce73e781)
This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a change to branch UNOMI-920-yaml-tostring
in repository https://gitbox.apache.org/repos/asf/unomi.git
at 3ce73e781 UNOMI-920 Improve toString() methods with YAML formatting for debugging
This branch includes the following new commits:
new 3ce73e781 UNOMI-920 Improve toString() methods with YAML formatting for debugging
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(unomi) 01/01: UNOMI-920 Improve toString() methods with YAML formatting for debugging
Posted by sh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch UNOMI-920-yaml-tostring
in repository https://gitbox.apache.org/repos/asf/unomi.git
commit 3ce73e78198edb511e0313c1b9f2f84cdcb106a0
Author: Serge Huber <sh...@apache.org>
AuthorDate: Tue May 5 17:17:56 2026 +0200
UNOMI-920 Improve toString() methods with YAML formatting for debugging
- Add YamlUtils (SnakeYaml) with YamlConvertible, YamlMapBuilder, circular
reference detection (identity-based visited sets), and recursion depth limits.
- Implement YAML-backed toString()/toYaml() on core API types extending Item /
MetadataItem (Condition, ConditionType, Action, ActionType, Rule, Segment,
Goal, Scoring, ScoringElement, Parameter, Metadata, etc.).
- Add YamlUtilsTest coverage for builder, toYamlValue, and representative rules.
Build and integration alignment:
- unomi-api: snakeyaml + test dependencies; manage mockito-core version in BOM.
- RESTParameter: use Object for defaultValue to match Parameter#getDefaultValue().
- RulesServiceImpl: avoid NPE when tracked parameter defaultValue is null before split.
- itests: override awaitility to 3.1.6 for OSGi (Hamcrest 1.3 bundle); Karaf
itests common logback exclusions; hamcrest bundle scope test.
---
api/pom.xml | 22 +
api/src/main/java/org/apache/unomi/api/Item.java | 62 ++-
.../main/java/org/apache/unomi/api/Metadata.java | 46 +-
.../java/org/apache/unomi/api/MetadataItem.java | 57 +-
.../main/java/org/apache/unomi/api/Parameter.java | 64 ++-
.../java/org/apache/unomi/api/actions/Action.java | 48 +-
.../org/apache/unomi/api/actions/ActionType.java | 40 +-
.../org/apache/unomi/api/conditions/Condition.java | 113 +++-
.../apache/unomi/api/conditions/ConditionType.java | 43 +-
.../main/java/org/apache/unomi/api/goals/Goal.java | 44 +-
.../main/java/org/apache/unomi/api/rules/Rule.java | 46 +-
.../org/apache/unomi/api/segments/Scoring.java | 38 +-
.../apache/unomi/api/segments/ScoringElement.java | 47 +-
.../org/apache/unomi/api/segments/Segment.java | 40 +-
.../java/org/apache/unomi/api/utils/YamlUtils.java | 319 +++++++++++
.../org/apache/unomi/api/utils/YamlUtilsTest.java | 610 +++++++++++++++++++++
bom/pom.xml | 20 +
itests/pom.xml | 22 +-
pom.xml | 4 +
.../apache/unomi/rest/models/RESTParameter.java | 6 +-
.../services/impl/rules/RulesServiceImpl.java | 10 +-
21 files changed, 1656 insertions(+), 45 deletions(-)
diff --git a/api/pom.xml b/api/pom.xml
index af8c96220..4d9ed7fbd 100644
--- a/api/pom.xml
+++ b/api/pom.xml
@@ -73,6 +73,28 @@
<artifactId>jackson-databind</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.yaml</groupId>
+ <artifactId>snakeyaml</artifactId>
+ </dependency>
+
+ <!-- Test Dependencies -->
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-core</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <!-- SLF4J Implementation for Testing -->
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-simple</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<reporting>
diff --git a/api/src/main/java/org/apache/unomi/api/Item.java b/api/src/main/java/org/apache/unomi/api/Item.java
index de283ebe9..9836cf40d 100644
--- a/api/src/main/java/org/apache/unomi/api/Item.java
+++ b/api/src/main/java/org/apache/unomi/api/Item.java
@@ -17,14 +17,18 @@
package org.apache.unomi.api;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Serializable;
-import java.util.HashMap;
-import java.util.Map;
+import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
+
/**
* A context server tracked entity. All tracked entities need to extend this class so as to provide the minimal information the context server needs to be able to track such
* entities and operate on them. Items are persisted according to their type (structure) and identifier (identity). Of note, all Item subclasses <strong>must</strong> define a
@@ -36,10 +40,10 @@ import java.util.concurrent.ConcurrentHashMap;
* though scopes could span across sites depending on the desired analysis granularity). Scopes allow clients accessing the context server to filter data. The context server
* defines a built-in scope ({@link Metadata#SYSTEM_SCOPE}) that clients can use to share data across scopes.
*/
-public abstract class Item implements Serializable {
+public abstract class Item implements Serializable, YamlConvertible {
private static final Logger LOGGER = LoggerFactory.getLogger(Item.class.getName());
- private static final long serialVersionUID = 7446061538573517071L;
+ private static final long serialVersionUID = 1217180125083162915L;
private static final Map<Class,String> itemTypeCache = new ConcurrentHashMap<>();
@@ -150,4 +154,54 @@ public abstract class Item implements Serializable {
public void setSystemMetadata(String key, Object value) {
systemMetadata.put(key, value);
}
+
+ /**
+ * Converts this item to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite recursion (may be null)
+ * @return a Map representation of this item
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("itemId", itemId)
+ .put("itemType", itemType)
+ .put("systemMetadata", "<max depth exceeded>")
+ .build();
+ }
+ final Set<Object> visitedSet = visited != null ? visited : new HashSet<>();
+ // Check if already visited - if so, we're being called from a child class via super.toYaml()
+ // OR it's a real circular reference. We can't distinguish, but since child classes
+ // (like Rule, ConditionType, etc.) all check for circular refs before calling super,
+ // if we're already visited here, it's safe to assume it's a super call, not a circular ref.
+ // If Item is directly serialized and encounters itself, the check would happen at the
+ // top level before nested processing, so this should be safe.
+ boolean alreadyVisited = visitedSet.contains(this);
+ if (!alreadyVisited) {
+ // First time seeing this object - add it to track for circular references
+ visitedSet.add(this);
+ }
+ try {
+ return YamlMapBuilder.create()
+ .put("itemId", itemId) // Always include, even if null, to reflect actual state
+ .put("itemType", itemType) // Always include, even if null, to reflect actual state
+ .putIfNotNull("scope", scope)
+ .putIfNotNull("version", version)
+ .putIfNotNull("systemMetadata", systemMetadata != null && !systemMetadata.isEmpty() ? toYamlValue(systemMetadata, visitedSet, maxDepth - 1) : null)
+ .build();
+ } finally {
+ // Only remove if we added it (i.e., if it wasn't already visited)
+ if (!alreadyVisited) {
+ visitedSet.remove(this);
+ }
+ }
+ }
+
+ @Override
+ public String toString() {
+ Map<String, Object> map = toYaml();
+ return YamlUtils.format(map);
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/Metadata.java b/api/src/main/java/org/apache/unomi/api/Metadata.java
index 9a112e766..ec25c7fa8 100644
--- a/api/src/main/java/org/apache/unomi/api/Metadata.java
+++ b/api/src/main/java/org/apache/unomi/api/Metadata.java
@@ -17,16 +17,24 @@
package org.apache.unomi.api;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
+
import java.io.Serializable;
+import java.util.HashSet;
import java.util.LinkedHashSet;
+import java.util.Map;
import java.util.Set;
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+
/**
* A class providing information about context server entities.
*
* @see MetadataItem
*/
-public class Metadata implements Comparable<Metadata>, Serializable {
+public class Metadata implements Comparable<Metadata>, Serializable, YamlConvertible {
private static final long serialVersionUID = 7446061538573517071L;
@@ -279,5 +287,41 @@ public class Metadata implements Comparable<Metadata>, Serializable {
return result;
}
+ /**
+ * Converts this metadata to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite recursion (may be null)
+ * @return a Map representation of this metadata
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited : new HashSet<>();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .putIfNotNull("id", id)
+ .putIfNotNull("name", name)
+ .putIfNotNull("description", description)
+ .putIfNotNull("scope", scope)
+ .putIfNotEmpty("tags", tags)
+ .putIfNotEmpty("systemTags", systemTags)
+ .putIf("enabled", true, enabled)
+ .putIf("missingPlugins", true, missingPlugins)
+ .putIf("hidden", true, hidden)
+ .putIf("readOnly", true, readOnly)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
+ @Override
+ public String toString() {
+ Map<String, Object> map = toYaml();
+ return YamlUtils.format(map);
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/MetadataItem.java b/api/src/main/java/org/apache/unomi/api/MetadataItem.java
index 78a419863..35ef596a2 100644
--- a/api/src/main/java/org/apache/unomi/api/MetadataItem.java
+++ b/api/src/main/java/org/apache/unomi/api/MetadataItem.java
@@ -17,8 +17,16 @@
package org.apache.unomi.api;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
+
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlTransient;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A superclass for all {@link Item}s that bear {@link Metadata}.
@@ -31,7 +39,7 @@ public abstract class MetadataItem extends Item {
}
public MetadataItem(Metadata metadata) {
- super(metadata.getId());
+ super(metadata != null ? metadata.getId() : null);
this.metadata = metadata;
}
@@ -54,7 +62,52 @@ public abstract class MetadataItem extends Item {
@XmlTransient
public String getScope() {
- return metadata.getScope();
+ if (metadata != null) {
+ return metadata.getScope();
+ }
+ return scope;
+ }
+
+ /**
+ * Converts this metadata item to a Map structure for YAML output.
+ * Merges fields from Item parent class and adds metadata field.
+ * Subclasses should override this method, call super.toYaml(visited), and add their specific fields.
+ *
+ * @param visited set of already visited objects to prevent infinite recursion (may be null)
+ * @return a Map representation of this metadata item
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("metadata", "<max depth exceeded>")
+ .build();
+ }
+ final Set<Object> visitedSet = visited != null ? visited : new HashSet<>();
+ // Check if already visited - if so, we're being called from a child class via super.toYaml()
+ // In that case, skip the circular reference check and just proceed
+ boolean alreadyVisited = visitedSet.contains(this);
+ if (!alreadyVisited) {
+ // Only check for circular references if this is the first time we're seeing this object
+ visitedSet.add(this);
+ }
+ try {
+ return YamlMapBuilder.create()
+ .mergeObject(super.toYaml(visitedSet, maxDepth))
+ .putIfNotNull("metadata", metadata != null ? toYamlValue(metadata, visitedSet, maxDepth - 1) : null)
+ .build();
+ } finally {
+ // Only remove if we added it (i.e., if it wasn't already visited)
+ if (!alreadyVisited) {
+ visitedSet.remove(this);
+ }
+ }
}
+
+ @Override
+ public String toString() {
+ Map<String, Object> map = toYaml();
+ return YamlUtils.format(map);
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/Parameter.java b/api/src/main/java/org/apache/unomi/api/Parameter.java
index 4833c5a5f..425d86280 100644
--- a/api/src/main/java/org/apache/unomi/api/Parameter.java
+++ b/api/src/main/java/org/apache/unomi/api/Parameter.java
@@ -17,20 +17,27 @@
package org.apache.unomi.api;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+
import java.io.Serializable;
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A representation of a condition parameter, to be used in the segment building UI to either select parameters from a
* choicelist or to enter a specific value.
*/
-public class Parameter implements Serializable {
+public class Parameter implements Serializable, YamlConvertible {
- private static final long serialVersionUID = 7446061538573517071L;
+ private static final long serialVersionUID = 6019392686888941547L;
- String id;
- String type;
- boolean multivalued = false;
- String defaultValue = null;
+ private String id;
+ private String type;
+ private boolean multivalued;
+ private Object defaultValue;
public Parameter() {
}
@@ -45,14 +52,26 @@ public class Parameter implements Serializable {
return id;
}
+ public void setId(String id) {
+ this.id = id;
+ }
+
public String getType() {
return type;
}
+ public void setType(String type) {
+ this.type = type;
+ }
+
public boolean isMultivalued() {
return multivalued;
}
+ public void setMultivalued(boolean multivalued) {
+ this.multivalued = multivalued;
+ }
+
/**
* @param choiceListInitializerFilter a reference to a choicelist
* @deprecated As of version 1.1.0-incubating
@@ -62,11 +81,40 @@ public class Parameter implements Serializable {
// Avoid errors when deploying old definitions
}
- public String getDefaultValue() {
+ public Object getDefaultValue() {
return defaultValue;
}
- public void setDefaultValue(String defaultValue) {
+ public void setDefaultValue(Object defaultValue) {
this.defaultValue = defaultValue;
}
+
+ /**
+ * Converts this parameter to a Map structure for YAML output.
+ * Implements YamlConvertible interface.
+ *
+ * @param visited set of already visited objects to prevent infinite recursion (may be null)
+ * @return a Map representation of this parameter
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlUtils.YamlMapBuilder.create()
+ .put("id", id)
+ .put("validation", "<max depth exceeded>")
+ .build();
+ }
+ return YamlUtils.YamlMapBuilder.create()
+ .putIfNotNull("id", id)
+ .putIfNotNull("type", type)
+ .putIf("multivalued", true, multivalued)
+ .putIfNotNull("defaultValue", defaultValue)
+ .build();
+ }
+
+ @Override
+ public String toString() {
+ return YamlUtils.format(toYaml());
+ }
+
}
diff --git a/api/src/main/java/org/apache/unomi/api/actions/Action.java b/api/src/main/java/org/apache/unomi/api/actions/Action.java
index b3505bbe8..29c6927e1 100644
--- a/api/src/main/java/org/apache/unomi/api/actions/Action.java
+++ b/api/src/main/java/org/apache/unomi/api/actions/Action.java
@@ -18,18 +18,26 @@
package org.apache.unomi.api.actions;
import org.apache.unomi.api.rules.Rule;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlTransient;
import java.io.Serializable;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* An action that can be executed as a consequence of a {@link Rule} being triggered. An action is characterized by its associated {@link
* ActionType} and parameter values.
*/
-public class Action implements Serializable {
+public class Action implements Serializable, YamlConvertible {
private ActionType actionType;
private String actionTypeId;
private Map<String, Object> parameterValues = new HashMap<>();
@@ -117,4 +125,42 @@ public class Action implements Serializable {
parameterValues.put(name, value);
}
+ /**
+ * Converts this action to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite recursion (may be null)
+ * @return a Map representation of this action
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("type", actionTypeId != null ? actionTypeId : "Action")
+ .put("parameterValues", "<max depth exceeded>")
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited : new HashSet<>();
+ visitedSet.add(this);
+ try {
+ YamlMapBuilder builder = YamlMapBuilder.create()
+ .put("type", actionTypeId != null ? actionTypeId : "Action");
+ if (parameterValues != null && !parameterValues.isEmpty()) {
+ builder.put("parameterValues", toYamlValue(parameterValues, visitedSet, maxDepth - 1));
+ }
+ return builder.build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
+
+ @Override
+ public String toString() {
+ Map<String, Object> map = toYaml();
+ return YamlUtils.format(map);
+ }
+
}
diff --git a/api/src/main/java/org/apache/unomi/api/actions/ActionType.java b/api/src/main/java/org/apache/unomi/api/actions/ActionType.java
index 5ff336d9f..e170c779b 100644
--- a/api/src/main/java/org/apache/unomi/api/actions/ActionType.java
+++ b/api/src/main/java/org/apache/unomi/api/actions/ActionType.java
@@ -20,14 +20,18 @@ package org.apache.unomi.api.actions;
import org.apache.unomi.api.Metadata;
import org.apache.unomi.api.MetadataItem;
import org.apache.unomi.api.Parameter;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
-import java.util.ArrayList;
-import java.util.List;
+import java.util.*;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A type definition for {@link Action}s.
*/
-public class ActionType extends MetadataItem {
+public class ActionType extends MetadataItem implements YamlConvertible {
public static final String ITEM_TYPE = "actionType";
private static final long serialVersionUID = -3522958600710010935L;
@@ -101,4 +105,34 @@ public class ActionType extends MetadataItem {
public int hashCode() {
return itemId.hashCode();
}
+
+ /**
+ * Converts this action type to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite recursion (may be null)
+ * @return a Map representation of this action type
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("parameters", "<max depth exceeded>")
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited : new HashSet<>();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .mergeObject(super.toYaml(visitedSet, maxDepth))
+ .putIfNotNull("actionExecutor", actionExecutor)
+ .putIfNotEmpty("parameters", parameters != null ? (Collection<?>) toYamlValue(parameters, visitedSet, maxDepth - 1) : null)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/conditions/Condition.java b/api/src/main/java/org/apache/unomi/api/conditions/Condition.java
index 526ff463f..812ee5862 100644
--- a/api/src/main/java/org/apache/unomi/api/conditions/Condition.java
+++ b/api/src/main/java/org/apache/unomi/api/conditions/Condition.java
@@ -17,16 +17,22 @@
package org.apache.unomi.api.conditions;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
+
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlTransient;
import java.io.Serializable;
-import java.util.HashMap;
-import java.util.Map;
+import java.util.*;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A set of elements that can be evaluated.
*/
-public class Condition implements Serializable {
+public class Condition implements Serializable, YamlConvertible {
private static final long serialVersionUID = 7584522402785053206L;
ConditionType conditionType;
@@ -65,7 +71,9 @@ public class Condition implements Serializable {
*/
public void setConditionType(ConditionType conditionType) {
this.conditionType = conditionType;
- this.conditionTypeId = conditionType.getItemId();
+ if (conditionType != null) {
+ this.conditionTypeId = conditionType.getItemId();
+ }
}
/**
@@ -123,7 +131,7 @@ public class Condition implements Serializable {
* @return the value of the specified parameter or {@code null} if no such parameter exists
*/
public Object getParameter(String name) {
- return parameterValues.get(name);
+ return parameterValues != null ? parameterValues.get(name) : null;
}
/**
@@ -156,13 +164,96 @@ public class Condition implements Serializable {
return result;
}
+ /**
+ * Converts this condition to a Map structure for YAML output with depth limiting.
+ * Implements YamlConvertible interface with circular reference detection and depth limiting
+ * to prevent StackOverflowError from extremely deep nested structures.
+ *
+ * @param visited set of already visited objects to prevent infinite recursion (may be null)
+ * @param maxDepth maximum recursion depth (prevents StackOverflowError from deep nesting)
+ * @return a Map representation of this condition
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("type", conditionTypeId != null ? conditionTypeId : "Condition")
+ .put("parameterValues", "<max depth exceeded>")
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited : new HashSet<>();
+ visitedSet.add(this);
+ try {
+ YamlMapBuilder builder = YamlMapBuilder.create()
+ .put("type", conditionTypeId != null ? conditionTypeId : "Condition");
+ if (parameterValues != null && !parameterValues.isEmpty()) {
+ builder.put("parameterValues", toYamlValue(parameterValues, visitedSet, maxDepth - 1));
+ }
+ return builder.build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
+
+ /**
+ * Creates a deep copy of this condition, including all nested conditions in parameter values.
+ * Recursively copies all nested conditions to avoid sharing references.
+ *
+ * @return a deep copy of this condition
+ */
+ public Condition deepCopy() {
+ Condition copied = new Condition();
+ if (this.conditionType != null) {
+ copied.setConditionType(this.conditionType);
+ } else if (this.conditionTypeId != null) {
+ copied.setConditionTypeId(this.conditionTypeId);
+ }
+
+ // Deep copy parameter values
+ Map<String, Object> copiedParams = new HashMap<>();
+ if (this.parameterValues != null) {
+ for (Map.Entry<String, Object> entry : this.parameterValues.entrySet()) {
+ Object value = entry.getValue();
+ if (value instanceof Condition) {
+ // Recursively deep copy nested condition
+ copiedParams.put(entry.getKey(), ((Condition) value).deepCopy());
+ } else if (value instanceof Collection) {
+ // Deep copy collection - preserve the collection type if possible
+ Collection<?> collection = (Collection<?>) value;
+ Collection<Object> copiedCollection;
+ if (collection instanceof List) {
+ copiedCollection = new ArrayList<>();
+ } else {
+ // Fallback to ArrayList for other collection types
+ copiedCollection = new ArrayList<>();
+ }
+ for (Object item : collection) {
+ if (item instanceof Condition) {
+ // Recursively deep copy nested condition
+ copiedCollection.add(((Condition) item).deepCopy());
+ } else {
+ // Not a condition, add as-is (for non-condition values in collections)
+ copiedCollection.add(item);
+ }
+ }
+ copiedParams.put(entry.getKey(), copiedCollection);
+ } else {
+ // Primitive or other non-condition value, copy as-is
+ copiedParams.put(entry.getKey(), value);
+ }
+ }
+ }
+ copied.setParameterValues(copiedParams);
+
+ return copied;
+ }
+
@Override
public String toString() {
- final StringBuilder sb = new StringBuilder("Condition{");
- sb.append("conditionType=").append(conditionType);
- sb.append(", conditionTypeId='").append(conditionTypeId).append('\'');
- sb.append(", parameterValues=").append(parameterValues);
- sb.append('}');
- return sb.toString();
+ Map<String, Object> map = toYaml();
+ return YamlUtils.format(map);
}
}
diff --git a/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java b/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java
index c8a43225c..e8af9c41f 100644
--- a/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java
+++ b/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java
@@ -20,10 +20,14 @@ package org.apache.unomi.api.conditions;
import org.apache.unomi.api.Metadata;
import org.apache.unomi.api.MetadataItem;
import org.apache.unomi.api.Parameter;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
import javax.xml.bind.annotation.XmlElement;
-import java.util.ArrayList;
-import java.util.List;
+import java.util.*;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* ConditionTypes define new conditions that can be applied to items (for example to decide whether a rule needs to be triggered or if a profile is considered as taking part in a
@@ -31,7 +35,7 @@ import java.util.List;
* optimized by coding it. They may also be defined as combination of other conditions. A simple condition could be: “User is male”, while a more generic condition with
* parameters may test whether a given property has a specific value: “User property x has value y”.
*/
-public class ConditionType extends MetadataItem {
+public class ConditionType extends MetadataItem implements YamlConvertible {
public static final String ITEM_TYPE = "conditionType";
private static final long serialVersionUID = -6965481691241954969L;
@@ -142,4 +146,37 @@ public class ConditionType extends MetadataItem {
public int hashCode() {
return itemId.hashCode();
}
+
+ /**
+ * Converts this condition type to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite recursion (may be null)
+ * @return a Map representation of this condition type
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("parentCondition", "<max depth exceeded>")
+ .put("parameters", "<max depth exceeded>")
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited : new HashSet<>();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .mergeObject(super.toYaml(visitedSet, maxDepth))
+ .putIfNotNull("conditionEvaluator", conditionEvaluator)
+ .putIfNotNull("queryBuilder", queryBuilder)
+ .putIfNotNull("parentCondition", parentCondition != null ? toYamlValue(parentCondition, visitedSet, maxDepth - 1) : null)
+ .putIfNotEmpty("parameters", parameters != null ? (Collection<?>) toYamlValue(parameters, visitedSet, maxDepth - 1) : null)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/goals/Goal.java b/api/src/main/java/org/apache/unomi/api/goals/Goal.java
index 1b2ff0e88..12694b3d7 100644
--- a/api/src/main/java/org/apache/unomi/api/goals/Goal.java
+++ b/api/src/main/java/org/apache/unomi/api/goals/Goal.java
@@ -21,6 +21,15 @@ import org.apache.unomi.api.Metadata;
import org.apache.unomi.api.MetadataItem;
import org.apache.unomi.api.campaigns.Campaign;
import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
+
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A tracked activity / action that can be accomplished by site (scope) visitors. These are tracked in general because they relate to specific business objectives or are
@@ -32,7 +41,7 @@ import org.apache.unomi.api.conditions.Condition;
* <li>audience filtering: any visitor is considered for scope-level goals while campaign-level goals only consider visitors who match the campaign's conditions
* </ul>
*/
-public class Goal extends MetadataItem {
+public class Goal extends MetadataItem implements YamlConvertible {
public static final String ITEM_TYPE = "goal";
private static final long serialVersionUID = 6131648013470949983L;
private Condition startEvent;
@@ -87,4 +96,37 @@ public class Goal extends MetadataItem {
public void setCampaignId(String campaignId) {
this.campaignId = campaignId;
}
+
+ /**
+ * Converts this goal to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite recursion (may be null)
+ * @return a Map representation of this goal
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("startEvent", "<max depth exceeded>")
+ .put("targetEvent", "<max depth exceeded>")
+ .put("campaignId", campaignId)
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited : new HashSet<>();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .mergeObject(super.toYaml(visitedSet, maxDepth))
+ .putIfNotNull("startEvent", startEvent != null ? toYamlValue(startEvent, visitedSet, maxDepth - 1) : null)
+ .putIfNotNull("targetEvent", targetEvent != null ? toYamlValue(targetEvent, visitedSet, maxDepth - 1) : null)
+ .putIfNotNull("campaignId", campaignId)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/rules/Rule.java b/api/src/main/java/org/apache/unomi/api/rules/Rule.java
index 0aedfb123..63750c0d4 100644
--- a/api/src/main/java/org/apache/unomi/api/rules/Rule.java
+++ b/api/src/main/java/org/apache/unomi/api/rules/Rule.java
@@ -20,10 +20,15 @@ package org.apache.unomi.api.rules;
import org.apache.unomi.api.*;
import org.apache.unomi.api.actions.Action;
import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
-import java.util.List;
+import java.util.*;
import java.util.stream.Collectors;
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
+
/**
* A conditional set of actions to be executed in response to incoming events. Triggering of rules is guarded by a condition: the rule is only triggered if the associated
* condition ({@link #getCondition()}) is satisfied. Once a rule triggers, a list of actions ({@link #getActions()} can be performed as consequences.
@@ -34,7 +39,7 @@ import java.util.stream.Collectors;
* We could also specify a priority for our rule in case it needs to be executed before other ones when similar conditions match. This is accomplished using the
* {@link #getPriority()} property.
*/
-public class Rule extends MetadataItem {
+public class Rule extends MetadataItem implements YamlConvertible {
/**
* The Rule ITEM_TYPE.
@@ -197,4 +202,41 @@ public class Rule extends MetadataItem {
public void setPriority(int priority) {
this.priority = priority;
}
+
+ /**
+ * Converts this rule to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite recursion (may be null)
+ * @return a Map representation of this rule
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("condition", "<max depth exceeded>")
+ .put("actions", "<max depth exceeded>")
+ .put("priority", priority)
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited : new HashSet<>();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .mergeObject(super.toYaml(visitedSet, maxDepth))
+ .putIfNotNull("condition", condition != null ? toYamlValue(condition, visitedSet, maxDepth - 1) : null)
+ .putIfNotEmpty("actions", actions != null ? (Collection<?>) toYamlValue(actions, visitedSet, maxDepth - 1) : null)
+ .putIfNotEmpty("linkedItems", linkedItems)
+ .putIf("raiseEventOnlyOnceForProfile", true, raiseEventOnlyOnceForProfile)
+ .putIf("raiseEventOnlyOnceForSession", true, raiseEventOnlyOnceForSession)
+ .putIf("raiseEventOnlyOnce", true, raiseEventOnlyOnce)
+ .putIf("priority", priority, priority != 0)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/segments/Scoring.java b/api/src/main/java/org/apache/unomi/api/segments/Scoring.java
index 6018fb376..3d82e6e7e 100644
--- a/api/src/main/java/org/apache/unomi/api/segments/Scoring.java
+++ b/api/src/main/java/org/apache/unomi/api/segments/Scoring.java
@@ -21,14 +21,19 @@ import org.apache.unomi.api.Item;
import org.apache.unomi.api.Metadata;
import org.apache.unomi.api.MetadataItem;
import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
-import java.util.List;
+import java.util.*;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A set of conditions associated with a value to assign to {@link Profile}s when matching so that the associated users can be scored along that
* dimension. Each {@link ScoringElement} is evaluated and matching profiles' scores are incremented with the associated value.
*/
-public class Scoring extends MetadataItem {
+public class Scoring extends MetadataItem implements YamlConvertible {
/**
* The Scoring ITEM_TYPE.
*
@@ -71,4 +76,33 @@ public class Scoring extends MetadataItem {
this.elements = elements;
}
+ /**
+ * Converts this scoring to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite recursion (may be null)
+ * @return a Map representation of this scoring
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("elements", "<max depth exceeded>")
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited : new HashSet<>();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .mergeObject(super.toYaml(visitedSet, maxDepth))
+ .putIfNotEmpty("elements", elements != null ? (Collection<?>) toYamlValue(elements, visitedSet, maxDepth - 1) : null)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
+
}
diff --git a/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java b/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java
index 1af5147fb..bfc5a21cc 100644
--- a/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java
+++ b/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java
@@ -18,13 +18,22 @@
package org.apache.unomi.api.segments;
import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
import java.io.Serializable;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A scoring dimension along profiles can be evaluated and associated value to be assigned.
*/
-public class ScoringElement implements Serializable {
+public class ScoringElement implements Serializable, YamlConvertible {
private Condition condition;
private int value;
@@ -69,4 +78,40 @@ public class ScoringElement implements Serializable {
public void setValue(int value) {
this.value = value;
}
+
+ /**
+ * Converts this scoring element to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite recursion (may be null)
+ * @return a Map representation of this scoring element
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("condition", "<max depth exceeded>")
+ .put("value", value)
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited : new HashSet<>();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .putIfNotNull("condition", condition != null ? toYamlValue(condition, visitedSet, maxDepth - 1) : null)
+ .put("value", value)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
+
+ @Override
+ public String toString() {
+ Map<String, Object> map = toYaml();
+ return YamlUtils.format(map);
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/segments/Segment.java b/api/src/main/java/org/apache/unomi/api/segments/Segment.java
index 4e0d33830..2729ec74d 100644
--- a/api/src/main/java/org/apache/unomi/api/segments/Segment.java
+++ b/api/src/main/java/org/apache/unomi/api/segments/Segment.java
@@ -22,13 +22,22 @@ import org.apache.unomi.api.Metadata;
import org.apache.unomi.api.MetadataItem;
import org.apache.unomi.api.Profile;
import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
+
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A dynamically evaluated group of similar profiles in order to categorize the associated users. To be considered part of a given segment, users must satisfies
* the segment’s condition. If they match, users are automatically added to the segment. Similarly, if at any given point during, they cease to satisfy the segment’s condition,
* they are automatically removed from it.
*/
-public class Segment extends MetadataItem {
+public class Segment extends MetadataItem implements YamlConvertible {
/**
* The Segment ITEM_TYPE.
@@ -72,4 +81,33 @@ public class Segment extends MetadataItem {
this.condition = condition;
}
+ /**
+ * Converts this segment to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite recursion (may be null)
+ * @return a Map representation of this segment
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("condition", "<max depth exceeded>")
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited : new HashSet<>();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .mergeObject(super.toYaml(visitedSet, maxDepth))
+ .putIfNotNull("condition", condition != null ? toYamlValue(condition, visitedSet, maxDepth - 1) : null)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
+
}
diff --git a/api/src/main/java/org/apache/unomi/api/utils/YamlUtils.java b/api/src/main/java/org/apache/unomi/api/utils/YamlUtils.java
new file mode 100644
index 000000000..664f63fb7
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/utils/YamlUtils.java
@@ -0,0 +1,319 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.unomi.api.utils;
+
+import org.yaml.snakeyaml.DumperOptions;
+import org.yaml.snakeyaml.Yaml;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * YAML utilities using SnakeYaml with fluent API wrapper.
+ * Provides utilities for building YAML structures and formatting them via SnakeYaml.
+ */
+public class YamlUtils {
+ // SnakeYaml instance with configured options
+ private static final Yaml YAML_INSTANCE;
+
+ static {
+ DumperOptions options = new DumperOptions();
+ options.setIndent(2);
+ options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
+ options.setPrettyFlow(true);
+ options.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN);
+ YAML_INSTANCE = new Yaml(options);
+ }
+
+ /**
+ * Interface for objects that can convert themselves to YAML Map structures.
+ */
+ public interface YamlConvertible {
+ /**
+ * Converts this object to a Map structure for YAML output with depth limiting.
+ * This method accepts an optional visited set to detect circular references and a max depth
+ * to prevent StackOverflowError from extremely deep nested structures.
+ *
+ * @param visited optional set of visited objects to detect circular references (may be null)
+ * @param maxDepth maximum recursion depth (prevents StackOverflowError from deep nesting)
+ * @return a Map representation of this object
+ */
+ Map<String, Object> toYaml(Set<Object> visited, int maxDepth);
+
+ /**
+ * Converts this object to a Map structure for YAML output.
+ * This method accepts an optional visited set to detect circular references.
+ * Uses a default max depth of 20 to prevent StackOverflowError.
+ *
+ * @param visited optional set of visited objects to detect circular references (may be null)
+ * @return a Map representation of this object
+ */
+ default Map<String, Object> toYaml(Set<Object> visited) {
+ return toYaml(visited, 20);
+ }
+
+ /**
+ * Converts this object to a Map structure for YAML output.
+ * This is a convenience method that calls toYaml(null, 20).
+ *
+ * @return a Map representation of this object
+ */
+ default Map<String, Object> toYaml() {
+ return toYaml(null, 20);
+ }
+ }
+
+ /**
+ * Fluent builder for creating YAML Map structures.
+ * Provides chaining methods to avoid repeating the map variable.
+ */
+ public static class YamlMapBuilder {
+ private final Map<String, Object> map;
+
+ private YamlMapBuilder() {
+ this.map = new LinkedHashMap<>();
+ }
+
+ /**
+ * Creates a new builder instance.
+ *
+ * @return a new YamlMapBuilder
+ */
+ public static YamlMapBuilder create() {
+ return new YamlMapBuilder();
+ }
+
+ /**
+ * Adds a field if the value is not null.
+ *
+ * @param key the key (must not be null)
+ * @param value the value (only added if not null)
+ * @return this builder for chaining
+ * @throws NullPointerException if key is null
+ */
+ public YamlMapBuilder putIfNotNull(String key, Object value) {
+ if (key == null) {
+ throw new NullPointerException("Key must not be null");
+ }
+ if (value != null) {
+ map.put(key, value);
+ }
+ return this;
+ }
+
+ /**
+ * Adds a field if the condition is true.
+ *
+ * @param key the key (must not be null)
+ * @param value the value (only added if condition is true)
+ * @param condition the condition
+ * @return this builder for chaining
+ * @throws NullPointerException if key is null
+ */
+ public YamlMapBuilder putIf(String key, Object value, boolean condition) {
+ if (key == null) {
+ throw new NullPointerException("Key must not be null");
+ }
+ if (condition) {
+ map.put(key, value);
+ }
+ return this;
+ }
+
+ /**
+ * Adds a field unconditionally.
+ *
+ * @param key the key (must not be null)
+ * @param value the value
+ * @return this builder for chaining
+ * @throws NullPointerException if key is null
+ */
+ public YamlMapBuilder put(String key, Object value) {
+ if (key == null) {
+ throw new NullPointerException("Key must not be null");
+ }
+ map.put(key, value);
+ return this;
+ }
+
+ /**
+ * Adds a field if the collection is not null and not empty.
+ *
+ * @param key the key (must not be null)
+ * @param collection the collection (only added if not null and not empty)
+ * @return this builder for chaining
+ * @throws NullPointerException if key is null
+ */
+ public YamlMapBuilder putIfNotEmpty(String key, java.util.Collection<?> collection) {
+ if (key == null) {
+ throw new NullPointerException("Key must not be null");
+ }
+ if (collection != null && !collection.isEmpty()) {
+ map.put(key, collection);
+ }
+ return this;
+ }
+
+ /**
+ * Merges all fields from a Map into this builder.
+ * This is useful for inheritance where subclasses want to include parent class fields.
+ *
+ * Usage in subclasses:
+ * <pre>
+ * return YamlMapBuilder.create()
+ * .mergeObject(super.toYaml(visitedSet))
+ * .putIfNotNull("field", value)
+ * .build();
+ * </pre>
+ *
+ * @param objectMap the Map containing fields to merge (may be null, in which case nothing is merged)
+ * @return this builder for chaining
+ */
+ public YamlMapBuilder mergeObject(Map<String, Object> objectMap) {
+ if (objectMap != null) {
+ objectMap.forEach(map::put);
+ }
+ return this;
+ }
+
+ /**
+ * Builds and returns a defensive copy of the map.
+ *
+ * @return a new LinkedHashMap containing the built entries
+ */
+ public Map<String, Object> build() {
+ return new LinkedHashMap<>(map);
+ }
+ }
+
+ /**
+ * Converts a Set to a sorted List for YAML output.
+ *
+ * @param set the set to convert
+ * @return a sorted list, or null if the set is null or empty
+ */
+ public static <T extends Comparable<T>> List<T> setToSortedList(Set<T> set) {
+ if (set == null || set.isEmpty()) {
+ return null;
+ }
+ return set.stream().sorted().collect(Collectors.toList());
+ }
+
+ /**
+ * Converts a Set to a sorted List using a mapper function.
+ *
+ * @param set the set to convert
+ * @param mapper the mapper function (must not be null)
+ * @return a sorted list, or null if the set is null or empty
+ * @throws NullPointerException if mapper is null
+ */
+ public static <T, R extends Comparable<R>> List<R> setToSortedList(Set<T> set, Function<T, R> mapper) {
+ if (mapper == null) {
+ throw new NullPointerException("Mapper function must not be null");
+ }
+ if (set == null || set.isEmpty()) {
+ return null;
+ }
+ return set.stream().map(mapper).sorted().collect(Collectors.toList());
+ }
+
+ /**
+ * Converts a value to YAML-compatible format, handling nested structures.
+ * For objects that implement YamlConvertible, circular reference detection is
+ * handled by passing the visited set to their toYaml() implementation.
+ *
+ * @param value the value to convert
+ * @param visited set of visited objects for circular reference detection (may be null)
+ * @return the converted value
+ */
+ public static Object toYamlValue(Object value, Set<Object> visited) {
+ return toYamlValue(value, visited, Integer.MAX_VALUE);
+ }
+
+ /**
+ * Converts a value to YAML-compatible format with depth limiting to prevent StackOverflowError.
+ * For objects that implement YamlConvertible, circular reference detection is
+ * handled by passing the visited set to their toYaml() implementation.
+ *
+ * @param value the value to convert
+ * @param visited set of visited objects for circular reference detection (may be null)
+ * @param maxDepth maximum recursion depth (prevents StackOverflowError from deep nesting)
+ * @return the converted value, or a placeholder if max depth exceeded
+ */
+ public static Object toYamlValue(Object value, Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return "<max depth exceeded>";
+ }
+ if (value == null) {
+ return null;
+ }
+ if (value instanceof YamlConvertible) {
+ // For YamlConvertible, get the Map and then process it as a Map to ensure sorting
+ // Pass maxDepth - 1 to the toYaml method to continue depth limiting
+ Map<String, Object> result = ((YamlConvertible) value).toYaml(visited, maxDepth - 1);
+ // Process the result as a Map to ensure it's sorted (this handles both sorting and recursive processing)
+ return toYamlValue(result, visited, maxDepth - 1);
+ }
+ if (value instanceof List) {
+ return ((List<?>) value).stream()
+ .map(item -> toYamlValue(item, visited, maxDepth - 1))
+ .collect(Collectors.toList());
+ }
+ if (value instanceof Map) {
+ Map<?, ?> inputMap = (Map<?, ?>) value;
+ Map<String, Object> result = new LinkedHashMap<>();
+
+ if (!inputMap.isEmpty()) {
+ // Sort entries alphabetically by key string representation
+ inputMap.entrySet().stream()
+ .sorted((e1, e2) -> String.valueOf(e1.getKey()).compareTo(String.valueOf(e2.getKey())))
+ .forEach(entry ->
+ result.put(String.valueOf(entry.getKey()), toYamlValue(entry.getValue(), visited, maxDepth - 1)));
+ }
+ return result;
+ }
+ return value;
+ }
+
+
+ /**
+ * Formats a value as YAML using SnakeYaml.
+ * This is a convenience method that delegates to SnakeYaml.
+ *
+ * @param value the value to format
+ * @return YAML string representation
+ */
+ public static String format(Object value) {
+ return YAML_INSTANCE.dump(value);
+ }
+
+ /**
+ * Creates a circular reference marker map.
+ *
+ * @return a map indicating a circular reference
+ */
+ public static Map<String, Object> circularRef() {
+ return YamlMapBuilder.create()
+ .put("$ref", "circular")
+ .build();
+ }
+}
diff --git a/api/src/test/java/org/apache/unomi/api/utils/YamlUtilsTest.java b/api/src/test/java/org/apache/unomi/api/utils/YamlUtilsTest.java
new file mode 100644
index 000000000..c40c066ca
--- /dev/null
+++ b/api/src/test/java/org/apache/unomi/api/utils/YamlUtilsTest.java
@@ -0,0 +1,610 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.api.utils;
+
+import org.apache.unomi.api.Metadata;
+import org.apache.unomi.api.actions.Action;
+import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.rules.Rule;
+import org.junit.Test;
+
+import java.util.*;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit tests for YamlUtils fluent API.
+ * Tests focus on our fluent API, not SnakeYaml's implementation.
+ */
+public class YamlUtilsTest {
+
+ @Test
+ public void testYamlMapBuilderCreate() {
+ YamlUtils.YamlMapBuilder builder = YamlUtils.YamlMapBuilder.create();
+ assertNotNull("Builder should be created", builder);
+ }
+
+ @Test
+ public void testYamlMapBuilderPut() {
+ Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+ .put("key1", "value1")
+ .put("key2", 42)
+ .build();
+ assertEquals("First value should be set", "value1", map.get("key1"));
+ assertEquals("Second value should be set", 42, map.get("key2"));
+ }
+
+ @Test
+ public void testYamlMapBuilderPutIfNotNull() {
+ Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+ .putIfNotNull("key1", "value1")
+ .putIfNotNull("key2", null)
+ .putIfNotNull("key3", "value3")
+ .build();
+ assertEquals("Non-null value should be set", "value1", map.get("key1"));
+ assertFalse("Null value should not be set", map.containsKey("key2"));
+ assertEquals("Another non-null value should be set", "value3", map.get("key3"));
+ }
+
+ @Test
+ public void testYamlMapBuilderPutIf() {
+ Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+ .putIf("key1", "value1", true)
+ .putIf("key2", "value2", false)
+ .putIf("key3", "value3", true)
+ .build();
+ assertEquals("Value with true condition should be set", "value1", map.get("key1"));
+ assertFalse("Value with false condition should not be set", map.containsKey("key2"));
+ assertEquals("Another value with true condition should be set", "value3", map.get("key3"));
+ }
+
+ @Test
+ public void testYamlMapBuilderPutIfNotEmpty() {
+ Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+ .putIfNotEmpty("key1", Arrays.asList("a", "b"))
+ .putIfNotEmpty("key2", Collections.emptyList())
+ .putIfNotEmpty("key3", null)
+ .putIfNotEmpty("key4", Arrays.asList("c"))
+ .build();
+ assertTrue("Non-empty collection should be set", map.containsKey("key1"));
+ assertFalse("Empty collection should not be set", map.containsKey("key2"));
+ assertFalse("Null collection should not be set", map.containsKey("key3"));
+ assertTrue("Another non-empty collection should be set", map.containsKey("key4"));
+ }
+
+ @Test
+ public void testYamlMapBuilderChaining() {
+ Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+ .put("a", 1)
+ .putIfNotNull("b", "value")
+ .putIf("c", 3, true)
+ .putIfNotEmpty("d", Arrays.asList(1, 2))
+ .build();
+ assertEquals("All valid entries should be added", 4, map.size());
+ }
+
+ @Test
+ public void testYamlMapBuilderNullKeyThrowsException() {
+ try {
+ YamlUtils.YamlMapBuilder.create().put(null, "value");
+ fail("Null key should throw NullPointerException");
+ } catch (NullPointerException e) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testYamlMapBuilderNullKeyInPutIfNotNull() {
+ try {
+ YamlUtils.YamlMapBuilder.create().putIfNotNull(null, "value");
+ fail("Null key in putIfNotNull should throw NullPointerException");
+ } catch (NullPointerException e) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testYamlMapBuilderNullKeyInPutIf() {
+ try {
+ YamlUtils.YamlMapBuilder.create().putIf(null, "value", true);
+ fail("Null key in putIf should throw NullPointerException");
+ } catch (NullPointerException e) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testYamlMapBuilderNullKeyInPutIfNotEmpty() {
+ try {
+ YamlUtils.YamlMapBuilder.create().putIfNotEmpty(null, Arrays.asList(1));
+ fail("Null key in putIfNotEmpty should throw NullPointerException");
+ } catch (NullPointerException e) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testYamlMapBuilderBuildReturnsNewMap() {
+ YamlUtils.YamlMapBuilder builder = YamlUtils.YamlMapBuilder.create();
+ builder.put("key", "value");
+ Map<String, Object> map1 = builder.build();
+ Map<String, Object> map2 = builder.build();
+ assertNotSame("Each build() should return a new map", map1, map2);
+ assertEquals("Both maps should have same content", map1, map2);
+ }
+
+ @Test
+ public void testSetToSortedList() {
+ Set<String> set = new LinkedHashSet<>(Arrays.asList("zebra", "apple", "banana"));
+ List<String> result = YamlUtils.setToSortedList(set);
+ assertNotNull("Result should not be null", result);
+ assertEquals("Set should be converted to sorted list", Arrays.asList("apple", "banana", "zebra"), result);
+ }
+
+ @Test
+ public void testSetToSortedListNull() {
+ List<String> result = YamlUtils.setToSortedList((Set<String>) null);
+ assertNull("Null set should return null", result);
+ }
+
+ @Test
+ public void testSetToSortedListEmpty() {
+ List<String> result = YamlUtils.setToSortedList(Collections.<String>emptySet());
+ assertNull("Empty set should return null", result);
+ }
+
+ @Test
+ public void testSetToSortedListWithMapper() {
+ Set<Integer> set = new LinkedHashSet<>(Arrays.asList(3, 1, 2));
+ List<String> result = YamlUtils.setToSortedList(set, String::valueOf);
+ assertNotNull("Result should not be null", result);
+ assertEquals("Set should be converted to sorted list using mapper", Arrays.asList("1", "2", "3"), result);
+ }
+
+ @Test
+ public void testSetToSortedListWithMapperNull() {
+ try {
+ YamlUtils.<Integer, String>setToSortedList(Collections.singleton(1), null);
+ fail("Null mapper should throw NullPointerException");
+ } catch (NullPointerException e) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testSetToSortedListWithMapperNullSet() {
+ List<String> result = YamlUtils.setToSortedList(null, String::valueOf);
+ assertNull("Null set should return null even with mapper", result);
+ }
+
+ @Test
+ public void testToYamlValueWithYamlConvertible() {
+ YamlUtils.YamlConvertible convertible = (visited, maxDepth) -> {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("test", "value");
+ return map;
+ };
+ Set<Object> visited = new HashSet<>();
+ Object result = YamlUtils.toYamlValue(convertible, visited);
+ assertTrue("YamlConvertible should be converted to Map", result instanceof Map);
+ Map<?, ?> map = (Map<?, ?>) result;
+ assertEquals("Converted map should contain test value", "value", map.get("test"));
+ }
+
+ @Test
+ public void testToYamlValueWithList() {
+ List<Object> list = Arrays.asList("a", "b", "c");
+ Set<Object> visited = new HashSet<>();
+ Object result = YamlUtils.toYamlValue(list, visited);
+ assertTrue("List should remain a List", result instanceof List);
+ assertEquals("List should be unchanged", list, result);
+ }
+
+ @Test
+ public void testToYamlValueWithMap() {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("key", "value");
+ Set<Object> visited = new HashSet<>();
+ Object result = YamlUtils.toYamlValue(map, visited);
+ assertTrue("Map should remain a Map", result instanceof Map);
+ assertEquals("Map should contain key-value", "value", ((Map<?, ?>) result).get("key"));
+ }
+
+ @Test
+ public void testToYamlValueWithNull() {
+ Set<Object> visited = new HashSet<>();
+ Object result = YamlUtils.toYamlValue(null, visited);
+ assertNull("Null should return null", result);
+ }
+
+ @Test
+ public void testToYamlValueWithPrimitive() {
+ Set<Object> visited = new HashSet<>();
+ Object result = YamlUtils.toYamlValue(42, visited);
+ assertEquals("Primitive should remain unchanged", 42, result);
+ }
+
+ @Test
+ public void testCircularRef() {
+ Map<String, Object> result = YamlUtils.circularRef();
+ assertNotNull("circularRef should return a map", result);
+ assertEquals("Should contain $ref: circular", "circular", result.get("$ref"));
+ assertEquals("Should have only one entry", 1, result.size());
+ }
+
+ @Test
+ public void testFormatBasic() {
+ // Just verify format() works - we don't test SnakeYaml's output format
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("key", "value");
+ String result = YamlUtils.format(map);
+ assertNotNull("Format should return a string", result);
+ assertTrue("Format should contain key", result.contains("key"));
+ assertTrue("Format should contain value", result.contains("value"));
+ }
+
+ // ========== Circular Reference Detection Tests ==========
+
+ @Test
+ public void testRuleInheritanceChainNoCircularRef() {
+ // Test that Rule -> MetadataItem -> Item inheritance chain doesn't produce false circular refs
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ metadata.setScope("systemscope");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("testCondition");
+ rule.setCondition(condition);
+
+ Map<String, Object> result = rule.toYaml(null);
+ assertNotNull("Rule should serialize to YAML", result);
+ assertFalse("Should not contain circular reference marker", result.containsKey("$ref"));
+ assertTrue("Should contain condition", result.containsKey("condition"));
+ assertTrue("Should contain itemId from Item parent", result.containsKey("itemId"));
+ assertTrue("Should contain metadata from MetadataItem parent", result.containsKey("metadata"));
+ }
+
+ @Test
+ public void testRuleWithCircularReferenceInCondition() {
+ // Test that a real circular reference (Rule referenced in condition's parameterValues) is detected
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("testCondition");
+ // Create a circular reference: condition's parameterValues contains the rule itself
+ condition.getParameterValues().put("referencedRule", rule);
+ rule.setCondition(condition);
+
+ Map<String, Object> result = rule.toYaml(null);
+ assertNotNull("Rule should serialize to YAML", result);
+ assertTrue("Should contain condition", result.containsKey("condition"));
+
+ // Check that the circular reference is detected in the condition's parameterValues
+ Map<String, Object> conditionMap = (Map<String, Object>) result.get("condition");
+ assertNotNull("Condition should be serialized", conditionMap);
+ Map<String, Object> paramValues = (Map<String, Object>) conditionMap.get("parameterValues");
+ assertNotNull("Parameter values should exist", paramValues);
+ Map<String, Object> circularRef = (Map<String, Object>) paramValues.get("referencedRule");
+ assertNotNull("Circular reference should be detected", circularRef);
+ assertEquals("Should contain circular reference marker", "circular", circularRef.get("$ref"));
+ }
+
+ @Test
+ public void testRuleWithCircularReferenceInActions() {
+ // Test circular reference in actions list
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Action action = new Action();
+ action.setActionTypeId("testAction");
+ // Create circular reference: action's parameterValues contains the rule
+ action.getParameterValues().put("triggeringRule", rule);
+ rule.setActions(Collections.singletonList(action));
+
+ Map<String, Object> result = rule.toYaml(null);
+ assertNotNull("Rule should serialize to YAML", result);
+ assertTrue("Should contain actions", result.containsKey("actions"));
+
+ List<?> actions = (List<?>) result.get("actions");
+ assertNotNull("Actions list should exist", actions);
+ assertEquals("Should have one action", 1, actions.size());
+
+ Map<String, Object> actionMap = (Map<String, Object>) actions.get(0);
+ Map<String, Object> paramValues = (Map<String, Object>) actionMap.get("parameterValues");
+ assertNotNull("Parameter values should exist", paramValues);
+ Map<String, Object> circularRef = (Map<String, Object>) paramValues.get("triggeringRule");
+ assertNotNull("Circular reference should be detected", circularRef);
+ assertEquals("Should contain circular reference marker", "circular", circularRef.get("$ref"));
+ }
+
+ @Test
+ public void testNestedCircularReference() {
+ // Test nested circular reference: Rule -> Condition -> nested Condition -> Rule
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Condition outerCondition = new Condition();
+ outerCondition.setConditionTypeId("outerCondition");
+
+ Condition nestedCondition = new Condition();
+ nestedCondition.setConditionTypeId("nestedCondition");
+ // Nested condition references the rule
+ nestedCondition.getParameterValues().put("ruleRef", rule);
+
+ // Outer condition contains nested condition
+ outerCondition.getParameterValues().put("nested", nestedCondition);
+ rule.setCondition(outerCondition);
+
+ Map<String, Object> result = rule.toYaml(null);
+ assertNotNull("Rule should serialize to YAML", result);
+
+ // Navigate through the nested structure
+ Map<String, Object> conditionMap = (Map<String, Object>) result.get("condition");
+ Map<String, Object> paramValues = (Map<String, Object>) conditionMap.get("parameterValues");
+ Map<String, Object> nestedConditionMap = (Map<String, Object>) paramValues.get("nested");
+ Map<String, Object> nestedParamValues = (Map<String, Object>) nestedConditionMap.get("parameterValues");
+ Map<String, Object> circularRef = (Map<String, Object>) nestedParamValues.get("ruleRef");
+
+ assertNotNull("Circular reference should be detected in nested structure", circularRef);
+ assertEquals("Should contain circular reference marker", "circular", circularRef.get("$ref"));
+ }
+
+ @Test
+ public void testMultipleCircularReferences() {
+ // Test multiple circular references to the same object
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("testCondition");
+ // Multiple references to the same rule
+ condition.getParameterValues().put("rule1", rule);
+ condition.getParameterValues().put("rule2", rule);
+ condition.getParameterValues().put("rule3", rule);
+ rule.setCondition(condition);
+
+ Map<String, Object> result = rule.toYaml(null);
+ Map<String, Object> conditionMap = (Map<String, Object>) result.get("condition");
+ Map<String, Object> paramValues = (Map<String, Object>) conditionMap.get("parameterValues");
+
+ // All three references should show circular ref
+ for (String key : Arrays.asList("rule1", "rule2", "rule3")) {
+ Map<String, Object> circularRef = (Map<String, Object>) paramValues.get(key);
+ assertNotNull("Circular reference should be detected for " + key, circularRef);
+ assertEquals("Should contain circular reference marker for " + key, "circular", circularRef.get("$ref"));
+ }
+ }
+
+
+ @Test
+ public void testCircularReferenceInList() {
+ // Test circular reference in a list
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("testCondition");
+ // List containing the rule itself
+ condition.getParameterValues().put("ruleList", Arrays.asList(rule, "other", rule));
+ rule.setCondition(condition);
+
+ Map<String, Object> result = rule.toYaml(null);
+ Map<String, Object> conditionMap = (Map<String, Object>) result.get("condition");
+ Map<String, Object> paramValues = (Map<String, Object>) conditionMap.get("parameterValues");
+ List<?> ruleList = (List<?>) paramValues.get("ruleList");
+
+ assertNotNull("Rule list should exist", ruleList);
+ assertEquals("List should have 3 elements", 3, ruleList.size());
+
+ // First element should be circular ref
+ Map<String, Object> circularRef1 = (Map<String, Object>) ruleList.get(0);
+ assertEquals("First element should be circular ref", "circular", circularRef1.get("$ref"));
+
+ // Second element should be string
+ assertEquals("Second element should be string", "other", ruleList.get(1));
+
+ // Third element should also be circular ref
+ Map<String, Object> circularRef2 = (Map<String, Object>) ruleList.get(2);
+ assertEquals("Third element should be circular ref", "circular", circularRef2.get("$ref"));
+ }
+
+ @Test
+ public void testCircularReferenceInNestedMap() {
+ // Test circular reference in nested map structure
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("testCondition");
+ // Nested map containing the rule
+ Map<String, Object> nestedMap = new HashMap<>();
+ nestedMap.put("level1", new HashMap<String, Object>() {{
+ put("level2", new HashMap<String, Object>() {{
+ put("rule", rule);
+ }});
+ }});
+ condition.getParameterValues().put("nested", nestedMap);
+ rule.setCondition(condition);
+
+ Map<String, Object> result = rule.toYaml(null);
+ Map<String, Object> conditionMap = (Map<String, Object>) result.get("condition");
+ Map<String, Object> paramValues = (Map<String, Object>) conditionMap.get("parameterValues");
+ Map<String, Object> nested = (Map<String, Object>) paramValues.get("nested");
+ Map<String, Object> level1 = (Map<String, Object>) nested.get("level1");
+ Map<String, Object> level2 = (Map<String, Object>) level1.get("level2");
+ Map<String, Object> circularRef = (Map<String, Object>) level2.get("rule");
+
+ assertNotNull("Circular reference should be detected in nested map", circularRef);
+ assertEquals("Should contain circular reference marker", "circular", circularRef.get("$ref"));
+ }
+
+ @Test
+ public void testNoFalseCircularRefInInheritance() {
+ // Test that inheritance chain (Rule -> MetadataItem -> Item) doesn't create false circular refs
+ // This is the main bug we're fixing
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ metadata.setScope("systemscope");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("unavailableConditionType");
+ condition.getParameterValues().put("comparisonOperator", "equals");
+ condition.getParameterValues().put("propertyName", "testProperty");
+ condition.getParameterValues().put("propertyValue", "testValue");
+ rule.setCondition(condition);
+
+ Action action = new Action();
+ action.setActionTypeId("test");
+ rule.setActions(Collections.singletonList(action));
+
+ Map<String, Object> result = rule.toYaml(null);
+
+ // Should NOT contain $ref: circular at the top level
+ assertNotNull("Rule should serialize", result);
+ assertFalse("Should not have false circular reference at top level",
+ result.containsKey("$ref") && "circular".equals(result.get("$ref")));
+
+ // Should contain all expected fields from inheritance chain
+ assertTrue("Should contain itemId from Item", result.containsKey("itemId"));
+ assertTrue("Should contain itemType from Item", result.containsKey("itemType"));
+ assertEquals("itemType should be 'rule'", "rule", result.get("itemType"));
+ assertTrue("Should contain metadata from MetadataItem", result.containsKey("metadata"));
+ assertTrue("Should contain condition", result.containsKey("condition"));
+ assertTrue("Should contain actions", result.containsKey("actions"));
+
+ // Verify condition structure
+ Map<String, Object> conditionMap = (Map<String, Object>) result.get("condition");
+ assertNotNull("Condition should be present", conditionMap);
+ assertEquals("Condition should have correct type", "unavailableConditionType", conditionMap.get("type"));
+
+ // Verify actions structure
+ List<?> actions = (List<?>) result.get("actions");
+ assertNotNull("Actions should be present", actions);
+ assertEquals("Should have one action", 1, actions.size());
+ }
+
+ @Test
+ public void testItemTypeIsAlwaysIncluded() {
+ // Test that itemType is always included in YAML output, even if null
+ // This reflects the actual state of the object
+ Rule rule = new Rule();
+ Metadata metadata = new Metadata("test-id");
+ metadata.setScope("systemscope");
+ rule.setMetadata(metadata);
+
+ Map<String, Object> result = rule.toYaml(null);
+
+ // itemType should always be present in output (set in Item constructor for Rule)
+ assertTrue("itemType should be included", result.containsKey("itemType"));
+ assertEquals("itemType should be 'rule'", "rule", result.get("itemType"));
+
+ // itemId should also always be included
+ assertTrue("itemId should be included", result.containsKey("itemId"));
+ }
+
+ @Test
+ public void testItemIdAndItemTypeIncludedEvenWhenNull() {
+ // Test that itemId and itemType are always included, even when null
+ // This ensures YAML output reflects the actual state of the object
+ Rule rule = new Rule();
+ // Explicitly set itemId and itemType to null to test null handling
+ rule.setItemId(null);
+ rule.setItemType(null);
+
+ Map<String, Object> result = rule.toYaml(null);
+
+ // Both should be included even if null
+ assertTrue("itemId should be included even when null", result.containsKey("itemId"));
+ assertNull("itemId should be null", result.get("itemId"));
+
+ assertTrue("itemType should be included even when null", result.containsKey("itemType"));
+ assertNull("itemType should be null", result.get("itemType"));
+ }
+
+ @Test
+ public void testItemIdFromMetadata() {
+ // Test that itemId is set from metadata and included in YAML
+ Rule rule = new Rule();
+ Metadata metadata = new Metadata("test-rule-id");
+ metadata.setScope("systemscope");
+ rule.setMetadata(metadata);
+
+ Map<String, Object> result = rule.toYaml(null);
+
+ // itemId should be set from metadata.getId()
+ assertTrue("itemId should be included when set from metadata", result.containsKey("itemId"));
+ assertEquals("itemId should match metadata id", "test-rule-id", result.get("itemId"));
+ }
+
+ @Test
+ public void testVisitedSetIsSharedCorrectly() {
+ // Test that visited set is properly shared across nested calls
+ Rule rule1 = new Rule();
+ rule1.setItemId("rule1");
+ rule1.setMetadata(new Metadata("rule1"));
+
+ Rule rule2 = new Rule();
+ rule2.setItemId("rule2");
+ rule2.setMetadata(new Metadata("rule2"));
+
+ // rule1 references rule2, rule2 references rule1 (mutual circular reference)
+ Condition condition1 = new Condition();
+ condition1.setConditionTypeId("test");
+ condition1.getParameterValues().put("otherRule", rule2);
+ rule1.setCondition(condition1);
+
+ Condition condition2 = new Condition();
+ condition2.setConditionTypeId("test");
+ condition2.getParameterValues().put("otherRule", rule1);
+ rule2.setCondition(condition2);
+
+ // Serialize rule1 - should detect circular ref when it encounters rule2 which references rule1
+ Map<String, Object> result1 = rule1.toYaml(null);
+ assertNotNull("Rule1 should serialize", result1);
+
+ Map<String, Object> conditionMap1 = (Map<String, Object>) result1.get("condition");
+ Map<String, Object> paramValues1 = (Map<String, Object>) conditionMap1.get("parameterValues");
+ Map<String, Object> rule2Ref = (Map<String, Object>) paramValues1.get("otherRule");
+
+ // rule2 should be serialized, but when it tries to reference rule1, it should detect circular ref
+ assertNotNull("Rule2 reference should exist", rule2Ref);
+ // rule2 itself should be fully serialized (not circular), but its condition's otherRule should be circular
+ Map<String, Object> conditionMap2 = (Map<String, Object>) rule2Ref.get("condition");
+ assertNotNull("Rule2's condition should exist", conditionMap2);
+ Map<String, Object> paramValues2 = (Map<String, Object>) conditionMap2.get("parameterValues");
+ Map<String, Object> rule1CircularRef = (Map<String, Object>) paramValues2.get("otherRule");
+ assertNotNull("Circular reference to rule1 should be detected", rule1CircularRef);
+ assertEquals("Should contain circular reference marker", "circular", rule1CircularRef.get("$ref"));
+ }
+}
diff --git a/bom/pom.xml b/bom/pom.xml
index 69cb5a051..17b0f3752 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -503,6 +503,26 @@
<artifactId>junit</artifactId>
<version>${junit.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <version>${junit-jupiter.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-core</artifactId>
+ <version>${mockito.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-junit-jupiter</artifactId>
+ <version>${mockito.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.awaitility</groupId>
+ <artifactId>awaitility</artifactId>
+ <version>${awaitility.version}</version>
+ </dependency>
</dependencies>
</dependencyManagement>
diff --git a/itests/pom.xml b/itests/pom.xml
index 98e98e9e5..d8b5d4a5e 100644
--- a/itests/pom.xml
+++ b/itests/pom.xml
@@ -32,6 +32,8 @@
<unomi.search.engine>elasticsearch</unomi.search.engine>
<use.opensearch>false</use.opensearch>
<docker.container.name>itests-opensearch</docker.container.name>
+ <!-- Skip caching for this module only -->
+ <maven.build.cache.skip>true</maven.build.cache.skip>
</properties>
<dependencyManagement>
@@ -43,6 +45,14 @@
<type>pom</type>
<scope>import</scope>
</dependency>
+ <!-- Override awaitility version for integration tests to use older version compatible with hamcrest 1.3 -->
+ <!-- Awaitility 4.0.0+ requires hamcrest 2.1+, but we need hamcrest 1.3 for OSGi bundle compatibility -->
+ <!-- Using awaitility 3.1.6 which works with hamcrest 1.3 -->
+ <dependency>
+ <groupId>org.awaitility</groupId>
+ <artifactId>awaitility</artifactId>
+ <version>3.1.6</version>
+ </dependency>
</dependencies>
</dependencyManagement>
@@ -53,6 +63,16 @@
<artifactId>common</artifactId>
<version>${karaf.version}</version>
<scope>test</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-classic</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-core</artifactId>
+ </exclusion>
+ </exclusions>
</dependency>
<!-- Define the Apache Karaf version to download and use for the test -->
<!-- We use a released version here to avoid SNAPSHOT resolution -->
@@ -105,7 +125,7 @@
<groupId>org.apache.servicemix.bundles</groupId>
<artifactId>org.apache.servicemix.bundles.hamcrest</artifactId>
<version>1.3_1</version>
- <scope>runtime</scope>
+ <scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
diff --git a/pom.xml b/pom.xml
index 25360797d..d4825e7ec 100644
--- a/pom.xml
+++ b/pom.xml
@@ -80,6 +80,7 @@
<snakeyaml.version>2.3</snakeyaml.version>
<opencsv.version>3.10</opencsv.version>
<log4j.version>2.19.0</log4j.version>
+ <slf4j.version>1.7.36</slf4j.version>
<lucene.version>9.12.2</lucene.version>
<failsafe.version>2.4.0</failsafe.version>
<joda-time.version>2.12.7</joda-time.version>
@@ -102,6 +103,9 @@
<httpclient-osgi.version>4.5.14</httpclient-osgi.version>
<httpcore-osgi.version>4.4.16</httpcore-osgi.version>
<junit.version>4.13.2</junit.version>
+ <junit-jupiter.version>5.8.2</junit-jupiter.version>
+ <mockito.version>4.5.1</mockito.version>
+ <awaitility.version>4.2.0</awaitility.version>
<kafka-client.version>2.2.1</kafka-client.version>
<st4.version>4.3.4</st4.version>
<commons-email.version>1.6.0</commons-email.version>
diff --git a/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java b/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java
index bc7ab9acf..d6257c6f0 100644
--- a/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java
+++ b/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java
@@ -26,7 +26,7 @@ public class RESTParameter {
private String id;
private String type;
private boolean multivalued = false;
- private String defaultValue = null;
+ private Object defaultValue = null;
public String getId() {
return id;
@@ -52,11 +52,11 @@ public class RESTParameter {
this.multivalued = multivalued;
}
- public String getDefaultValue() {
+ public Object getDefaultValue() {
return defaultValue;
}
- public void setDefaultValue(String defaultValue) {
+ public void setDefaultValue(Object defaultValue) {
this.defaultValue = defaultValue;
}
diff --git a/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java
index fe3fe8fb0..8b6e7063c 100644
--- a/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java
+++ b/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java
@@ -450,7 +450,15 @@ public class RulesServiceImpl implements RulesService, EventListenerService, Syn
trackedCondition.getConditionType().getParameters().forEach(parameter -> {
try {
if (TRACKED_PARAMETER.equals(parameter.getId())) {
- Arrays.stream(StringUtils.split(parameter.getDefaultValue(), ",")).forEach(trackedParameter -> {
+ // Parameter#getDefaultValue is Object; null must not call toString() (NPE) or be passed to split.
+ Object defaultValue = parameter.getDefaultValue();
+ if (defaultValue == null) {
+ LOGGER.debug(
+ "Skipping tracked parameter mapping: parameter id={} has null defaultValue for condition type {}",
+ parameter.getId(), trackedCondition.getConditionType().getItemId());
+ return;
+ }
+ Arrays.stream(StringUtils.split(defaultValue.toString(), ",")).forEach(trackedParameter -> {
String[] param = StringUtils.split(StringUtils.trim(trackedParameter), ":");
trackedParameters.put(StringUtils.trim(param[1]), trackedCondition.getParameter(StringUtils.trim(param[0])));
});