diff --git a/src/main/java/com/norconex/commons/lang/bean/BeanMapper.java b/src/main/java/com/norconex/commons/lang/bean/BeanMapper.java
index dfb324a3..e7baedf0 100644
--- a/src/main/java/com/norconex/commons/lang/bean/BeanMapper.java
+++ b/src/main/java/com/norconex/commons/lang/bean/BeanMapper.java
@@ -61,7 +61,6 @@
import com.fasterxml.jackson.databind.deser.DeserializationProblemHandler;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.jsontype.TypeIdResolver;
-import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.type.TypeFactory;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
@@ -73,14 +72,14 @@
import com.norconex.commons.lang.ClassUtil;
import com.norconex.commons.lang.bean.jackson.EmptyWithClosingTagXmlFactory;
import com.norconex.commons.lang.bean.jackson.JsonXmlCollectionModule;
-import com.norconex.commons.lang.bean.jackson.JsonXmlPropertiesDeserializer;
+import com.norconex.commons.lang.bean.jackson.JsonXmlMapModule;
+import com.norconex.commons.lang.bean.jackson.JsonXmlPropertiesModule;
import com.norconex.commons.lang.bean.spi.PolymorphicTypeLoader;
import com.norconex.commons.lang.bean.spi.PolymorphicTypeProvider;
import com.norconex.commons.lang.config.Configurable;
import com.norconex.commons.lang.convert.GenericJsonModule;
import com.norconex.commons.lang.flow.FlowMapperConfig;
import com.norconex.commons.lang.flow.module.FlowModule;
-import com.norconex.commons.lang.map.Properties;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
@@ -163,9 +162,6 @@ public enum Format {
.setProperty(
XMLOutputFactory.IS_REPAIRING_NAMESPACES,
true);
- m.registerModule(new SimpleModule().addDeserializer(
- Properties.class,
- new JsonXmlPropertiesDeserializer()));
return m;
}),
JSON(
@@ -479,6 +475,8 @@ public ObjectMapper toObjectMapper(Format format) {
// misc:
if (format == Format.XML) {
mapper.registerModule(new JsonXmlCollectionModule());
+ mapper.registerModule(new JsonXmlMapModule());
+ mapper.registerModule(new JsonXmlPropertiesModule());
}
mapper.addMixIn(Object.class, NonDefaultInclusionMixIn.class);
diff --git a/src/main/java/com/norconex/commons/lang/bean/jackson/JsonXmlMap.java b/src/main/java/com/norconex/commons/lang/bean/jackson/JsonXmlMap.java
new file mode 100644
index 00000000..a0eb2ed5
--- /dev/null
+++ b/src/main/java/com/norconex/commons/lang/bean/jackson/JsonXmlMap.java
@@ -0,0 +1,85 @@
+/* Copyright 2023 Norconex Inc.
+ *
+ * Licensed 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 com.norconex.commons.lang.bean.jackson;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+import java.util.HashMap;
+
+import com.norconex.commons.lang.bean.BeanMapper;
+
+/**
+ *
+ * This annotation must be used with the {@link JsonXmlMapModule}.
+ * The module is registered automatically for XML source if you are using
+ * {@link BeanMapper}.
+ * Use this annotation only when you wish to overwrite default settings
+ * via its attributes. It is otherwise of no use.
+ *
+ * @since 3.0.0
+ */
+@Retention(RUNTIME)
+@Target({ TYPE, FIELD })
+public @interface JsonXmlMap {
+
+ /**
+ * Field name to use for each map entries when serializing as XML.
+ * This name does not affect reading. Default is "entry".
+ * @return entry field name, when writing XML
+ */
+ public String entryName() default "entry";
+
+ /**
+ * Field name to use for each map entry keys when serializing as XML.
+ * Default is "key".
+ * @return entry key field name, when writing XML
+ */
+ public String keyName() default "key";
+
+ /**
+ * Field name to use for each map entry values when serializing as XML.
+ * Default is "value".
+ * @return entry value field name, when writing XML
+ */
+ public String valueName() default "value";
+
+ /**
+ * Concrete Map type to use when deserializing. Has to be assignable
+ * to the type of your Map property. Default will try to detect and
+ * use {@link HashMap} as fallback.
+ * @return map concrete type
+ */
+ public Class> mapType() default Void.class;
+
+ /**
+ * Concrete key type to use when deserializing. Has to be assignable
+ * to the type of your Map entry key property.
+ * Default will try to detect.
+ * @return map entry key concrete type
+ */
+ public Class> keyType() default Void.class;
+
+ /**
+ * Concrete value type to use when deserializing. Has to be assignable
+ * to the type of your Map entry value property.
+ * Default will try to detect.
+ * @return map entry value concrete type
+ */
+ public Class> valueType() default Void.class;
+}
diff --git a/src/main/java/com/norconex/commons/lang/bean/jackson/JsonXmlMapDeserializer.java b/src/main/java/com/norconex/commons/lang/bean/jackson/JsonXmlMapDeserializer.java
new file mode 100644
index 00000000..933f53b4
--- /dev/null
+++ b/src/main/java/com/norconex/commons/lang/bean/jackson/JsonXmlMapDeserializer.java
@@ -0,0 +1,159 @@
+/* Copyright 2024 Norconex Inc.
+ *
+ * Licensed 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 com.norconex.commons.lang.bean.jackson;
+
+import static org.apache.commons.lang3.StringUtils.defaultIfBlank;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.BeanProperty;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JavaType;
+import com.fasterxml.jackson.databind.deser.ContextualDeserializer;
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
+import com.norconex.commons.lang.ClassUtil;
+
+/**
+ * Deserializes a list of "entry" elements each having a "key" and "value"
+ * child elements into a Map.
+ * @param Map concrete type
+ */
+public class JsonXmlMapDeserializer>
+ extends StdDeserializer
+ implements ContextualDeserializer {
+
+ private static final long serialVersionUID = 1L;
+
+ private transient BeanProperty currentProperty;
+ private JavaType mapType;
+
+ public JsonXmlMapDeserializer() {
+ this((Class) null);
+ }
+
+ public JsonXmlMapDeserializer(Class vc) {
+ super(vc);
+ }
+
+ private JsonXmlMapDeserializer(
+ BeanProperty currentProperty, JavaType mapType) {
+ super(Map.class);
+ this.currentProperty = currentProperty;
+ this.mapType = mapType;
+ }
+
+ @Override
+ public StdDeserializer> createContextual(
+ DeserializationContext ctxt, BeanProperty property) {
+ return new JsonXmlMapDeserializer<>(
+ property,
+ property != null
+ ? property.getType()
+ : ctxt.constructType(Map.class));
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public T deserialize(JsonParser p, DeserializationContext ctxt)
+ throws IOException {
+ var map = createMap();
+ var keyName = "key";
+ var valueName = "value";
+ Class> keyType = null;
+ Class> valueType = null;
+ if (currentProperty != null) {
+ var annot = currentProperty.getAnnotation(JsonXmlMap.class);
+ if (annot != null) {
+ keyName = defaultIfBlank(annot.keyName(), keyName);
+ valueName = defaultIfBlank(annot.valueName(), valueName);
+ if (!Void.class.equals(annot.keyType())) {
+ keyType = annot.keyType();
+ }
+ if (!Void.class.equals(annot.valueType())) {
+ valueType = annot.valueType();
+ }
+ }
+ }
+
+ while (p.nextToken() != JsonToken.END_OBJECT) {
+
+ if (p.getCurrentToken() == JsonToken.START_OBJECT) {
+ Object key = null;
+ Object value = null;
+
+ while (p.nextToken() != JsonToken.END_OBJECT) {
+ var fieldName = p.currentName();
+ p.nextToken(); // Move to the value of the field
+
+ if (keyName.equals(fieldName)) {
+ key = ctxt.readValue(p,
+ fieldType(keyType, mapType.containedType(0)));
+ } else if (valueName.equals(fieldName)) {
+ value = ctxt.readValue(p,
+ fieldType(valueType, mapType.containedType(1)));
+ }
+ }
+
+ if (key != null) {
+ map.put(key, value);
+ }
+ }
+ }
+ return (T) map;
+ }
+
+ private Class> fieldType(Class> annotType, JavaType containedType) {
+ if (annotType != null) {
+ return annotType;
+ }
+ if (containedType != null) {
+ return containedType.getRawClass();
+ }
+ return null;
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map