Merge pull request #14 from apache/feature/SLING-8419

SLING-8419 refactor method to serialize OSGi configs as JSON
diff --git a/pom.xml b/pom.xml
index 0af896f..4c0453e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,29 +1,30 @@
 <?xml version="1.0" encoding="ISO-8859-1"?>
-    <!--
-        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
+<!--
+    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.
-    -->
-<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.apache.sling</groupId>
         <artifactId>sling</artifactId>
         <version>34</version>
-        <relativePath />
+        <relativePath/>
     </parent>
 
     <artifactId>org.apache.sling.feature.io</artifactId>
     <version>1.1.1-SNAPSHOT</version>
     <packaging>bundle</packaging>
-    
+
     <name>Apache Sling Feature IO Module</name>
     <description>
         IO functionality for the Feature Model
@@ -37,8 +38,8 @@
         <connection>scm:git:https://gitbox.apache.org/repos/asf/sling-org-apache-sling-feature-io.git</connection>
         <developerConnection>scm:git:https://gitbox.apache.org/repos/asf/sling-org-apache-sling-feature-io.git</developerConnection>
         <url>https://gitbox.apache.org/repos/asf?p=sling-org-apache-sling-feature-io.git</url>
-      <tag>HEAD</tag>
-  </scm>
+        <tag>HEAD</tag>
+    </scm>
 
     <build>
         <plugins>
@@ -184,5 +185,11 @@
             <version>1.0.0</version>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+            <version>3.9</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 </project>
diff --git a/src/main/java/org/apache/sling/feature/io/CloseShieldWriter.java b/src/main/java/org/apache/sling/feature/io/CloseShieldWriter.java
new file mode 100644
index 0000000..368fd28
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/CloseShieldWriter.java
@@ -0,0 +1,41 @@
+/*
+ * 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.sling.feature.io;
+
+import java.io.FilterWriter;
+import java.io.IOException;
+import java.io.Writer;
+
+/**
+ * Proxy writer that prevents the underlying writer from being closed.
+ * <p>
+ * This class is typically used in cases where a writer needs to be passed to a component that wants to explicitly close
+ * the writer even if other components would still use the writer for output.
+ * </p>
+ */
+public class CloseShieldWriter extends FilterWriter {
+
+    public CloseShieldWriter(Writer out) {
+        super(out);
+    }
+
+    @Override
+    public void close() throws IOException {
+        // NOOP
+    }
+
+}
diff --git a/src/main/java/org/apache/sling/feature/io/json/ConfigurationJSONWriter.java b/src/main/java/org/apache/sling/feature/io/json/ConfigurationJSONWriter.java
index 3732a5e..2e57abe 100644
--- a/src/main/java/org/apache/sling/feature/io/json/ConfigurationJSONWriter.java
+++ b/src/main/java/org/apache/sling/feature/io/json/ConfigurationJSONWriter.java
@@ -18,39 +18,162 @@
 
 import java.io.IOException;
 import java.io.Writer;
+import java.lang.reflect.Array;
+import java.util.Collection;
+import java.util.Dictionary;
+import java.util.Enumeration;
+import java.util.Iterator;
 
 import javax.json.stream.JsonGenerator;
 
+import org.apache.sling.feature.Configuration;
 import org.apache.sling.feature.Configurations;
 
-
-/**
- * JSON writer for configurations
- */
+/** JSON writer for configurations */
 public class ConfigurationJSONWriter extends JSONWriterBase {
 
-    /**
-     * Writes the configurations to the writer.
-     * The writer is not closed.
+    /** Writes the configurations to the writer. The writer is not closed.
+     * 
      * @param writer Writer
      * @param configs List of configurations
-     * @throws IOException If writing fails
-     */
+     * @throws IOException If writing fails */
     public static void write(final Writer writer, final Configurations configs)
-    throws IOException {
+            throws IOException {
         final ConfigurationJSONWriter w = new ConfigurationJSONWriter();
         w.writeConfigurations(writer, configs);
     }
 
     private void writeConfigurations(final Writer writer, final Configurations configs)
-    throws IOException {
+            throws IOException {
         JsonGenerator generator = newGenerator(writer);
-
-        // TODO is this correct?
-        generator.writeStartObject(JSONConstants.FEATURE_CONFIGURATIONS);
         writeConfigurations(generator, configs);
-        generator.writeEnd();
-
         generator.close();
     }
+
+    /** Write the OSGi configuration to a JSON structure as defined in
+     * <a href="https://osgi.org/specification/osgi.cmpn/7.0.0/service.configurator.html#d0e131765">OSGi Configurator Specification 1.0</a>.
+     * The writer is not closed.
+     * 
+     * @param writer Writer
+     * @param props The configuration properties to write */
+    public static void writeConfiguration(final Writer writer, final Dictionary<String, Object> props) {
+        final ConfigurationJSONWriter w = new ConfigurationJSONWriter();
+        JsonGenerator generator = w.newGenerator(writer);
+        generator.writeStartObject();
+        writeConfiguration(generator, props);
+        generator.writeEnd();
+        generator.close();
+    }
+
+    protected static void writeConfiguration(final JsonGenerator generator, final Dictionary<String, Object> props) {
+        final Enumeration<String> e = props.keys();
+        while (e.hasMoreElements()) {
+            final String name = e.nextElement();
+            if (Configuration.PROP_ARTIFACT_ID.equals(name)) {
+                continue;
+            }
+            final Object val = props.get(name);
+            writeConfigurationProperty(generator, name, val);
+        }
+    }
+
+    private static void writeConfigurationProperty(JsonGenerator generator, String name, Object val) {
+        String dataType = getDataType(val);
+        writeConfigurationProperty(generator, name, dataType, val);
+    }
+
+    private static void writeConfigurationProperty(JsonGenerator generator, String name, String dataType, Object val) {
+        String nameWithDataPostFix = name;
+        if (dataType != null) {
+            nameWithDataPostFix += ":" + dataType;
+        }
+        if (val.getClass().isArray()) {
+            generator.writeStartArray(nameWithDataPostFix);
+            for (int i = 0; i < Array.getLength(val); i++) {
+                writeArrayItem(generator, Array.get(val, i));
+            }
+            generator.writeEnd();
+        } else if (val instanceof Collection) {
+            generator.writeStartArray(nameWithDataPostFix);
+            for (Object item : Collection.class.cast(val)) {
+                writeArrayItem(generator, item);
+            }
+            generator.writeEnd();
+        } else {
+            writeNameValuePair(generator, nameWithDataPostFix, val);
+        }
+    }
+
+    private static void writeNameValuePair(JsonGenerator generator, String name, Object item) {
+        if (item instanceof Boolean) {
+            generator.write(name, (Boolean) item);
+        } else if (item instanceof Long || item instanceof Integer || item instanceof Byte || item instanceof Short) {
+            generator.write(name, ((Number)item).longValue());
+        } else if (item instanceof Double) {
+            generator.write(name, (Double) item);
+        } else if (item instanceof Float) {
+            generator.write(name, (Float) item);
+        } else {
+            generator.write(name, item.toString());
+        }
+    }
+
+    private static void writeArrayItem(JsonGenerator generator, Object item) {
+        if (item instanceof Boolean) {
+            generator.write((Boolean) item);
+        } else if (item instanceof Long || item instanceof Integer || item instanceof Byte || item instanceof Short) {
+            generator.write(((Number)item).longValue());
+        } else if (item instanceof Double) {
+            generator.write((Double) item);
+        } else if (item instanceof Float) {
+            generator.write((Float) item);
+        } else {
+            generator.write(item.toString());
+        }
+    }
+
+    private static String getDataType(Object object) {
+        if (object instanceof Collection) {
+            // check class of first item
+            Iterator<?> it = ((Collection<?>) object).iterator();
+            if (it.hasNext()) {
+                Class<?> itemClass = it.next().getClass();
+                return "Collection<" + getDataType(itemClass, false) + ">";
+            } else {
+                throw new IllegalStateException("Empty collections are invalid");
+            }
+        } else {
+            return getDataType(object.getClass(), true);
+        }
+    }
+
+    private static String getDataType(Class<?> clazz, boolean allowEmpty) {
+        if (clazz.isArray()) {
+            String dataType = getDataType(clazz.getComponentType(), false);
+            if (dataType != null) {
+                return dataType + "[]";
+            } else {
+                return null;
+            }
+        }
+        // default classes used by native JSON types
+        else if (clazz.isAssignableFrom(Boolean.class) || clazz.isAssignableFrom(boolean.class) || clazz.isAssignableFrom(Long.class) || clazz.isAssignableFrom(long.class) ||
+                clazz.isAssignableFrom(Double.class) || clazz.isAssignableFrom(double.class) || clazz.isAssignableFrom(String.class)) {
+            // no data type necessary except when being used in an array/collection
+            if (!allowEmpty) {
+                // for all other cases just use the simple name
+                return clazz.getSimpleName();
+            }
+
+        } else if (clazz.isAssignableFrom(Integer.class) || clazz.isAssignableFrom(int.class) || clazz.isAssignableFrom(Float.class) || clazz.isAssignableFrom(float.class)
+                || clazz.isAssignableFrom(Byte.class) || clazz.isAssignableFrom(byte.class) || clazz.isAssignableFrom(Short.class) || clazz.isAssignableFrom(short.class)
+                || clazz.isAssignableFrom(Character.class) || clazz.isAssignableFrom(char.class)) {
+            return clazz.getSimpleName();
+        }
+        if (!allowEmpty) {
+            throw new IllegalStateException("Class does not have a valid type " + clazz);
+        }
+        return null;
+
+    }
 }
diff --git a/src/main/java/org/apache/sling/feature/io/json/FeatureJSONWriter.java b/src/main/java/org/apache/sling/feature/io/json/FeatureJSONWriter.java
index 2c7af7c..af36576 100644
--- a/src/main/java/org/apache/sling/feature/io/json/FeatureJSONWriter.java
+++ b/src/main/java/org/apache/sling/feature/io/json/FeatureJSONWriter.java
@@ -47,6 +47,13 @@
     	// protected constructor for subclassing
     }
 
+    /**
+     * Writes the feature to the writer.
+     * The writer is not closed.
+     * @param writer Writer
+     * @param feature Feature
+     * @throws IOException If writing fails
+     */
     protected void writeFeature(final Writer writer, final Feature feature)
     throws IOException {
         JsonGenerator generator = newGenerator(writer);
diff --git a/src/main/java/org/apache/sling/feature/io/json/JSONWriterBase.java b/src/main/java/org/apache/sling/feature/io/json/JSONWriterBase.java
index 66b6ed2..d88f288 100644
--- a/src/main/java/org/apache/sling/feature/io/json/JSONWriterBase.java
+++ b/src/main/java/org/apache/sling/feature/io/json/JSONWriterBase.java
@@ -17,10 +17,8 @@
 package org.apache.sling.feature.io.json;
 
 import java.io.Writer;
-import java.lang.reflect.Array;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Enumeration;
 import java.util.List;
 import java.util.Map;
 
@@ -37,6 +35,7 @@
 import org.apache.sling.feature.ExtensionType;
 import org.apache.sling.feature.MatchingRequirement;
 import org.apache.sling.feature.Prototype;
+import org.apache.sling.feature.io.CloseShieldWriter;
 import org.osgi.resource.Capability;
 import org.osgi.resource.Requirement;
 
@@ -48,7 +47,9 @@
     private final JsonGeneratorFactory generatorFactory = Json.createGeneratorFactory(Collections.singletonMap(JsonGenerator.PRETTY_PRINTING, true));
 
     protected final JsonGenerator newGenerator(final Writer writer) {
-        return generatorFactory.createGenerator(writer);
+        // prevent closing of the underlying writer
+        Writer closeShieldWriter = new CloseShieldWriter(writer);
+        return generatorFactory.createGenerator(closeShieldWriter);
     }
 
     protected void writeBundles(final JsonGenerator generator,
@@ -104,75 +105,7 @@
 
         for(final Configuration cfg : cfgs) {
             generator.writeStartObject(cfg.getPid());
-
-            final Enumeration<String> e = cfg.getProperties().keys();
-            while ( e.hasMoreElements() ) {
-                final String name = e.nextElement();
-                if ( Configuration.PROP_ARTIFACT_ID.equals(name) ) {
-                    continue;
-                }
-
-                final Object val = cfg.getProperties().get(name);
-
-                String typePostFix = null;
-                final Object typeCheck;
-                if ( val.getClass().isArray() ) {
-                    if ( Array.getLength(val) > 0 ) {
-                        typeCheck = Array.get(val, 0);
-                    } else {
-                        typeCheck = null;
-                    }
-                } else {
-                    typeCheck = val;
-                }
-
-                if ( typeCheck instanceof Integer ) {
-                    typePostFix = ":Integer";
-                } else if ( typeCheck instanceof Byte ) {
-                    typePostFix = ":Byte";
-                } else if ( typeCheck instanceof Character ) {
-                    typePostFix = ":Character";
-                } else if ( typeCheck instanceof Float ) {
-                    typePostFix = ":Float";
-                }
-
-                if ( val.getClass().isArray() ) {
-                    generator.writeStartArray(name);
-                    for(int i=0; i<Array.getLength(val);i++ ) {
-                        final Object obj = Array.get(val, i);
-                        if ( typePostFix == null ) {
-                            if ( obj instanceof String ) {
-                                generator.write((String)obj);
-                            } else if ( obj instanceof Boolean ) {
-                                generator.write((Boolean)obj);
-                            } else if ( obj instanceof Long ) {
-                                generator.write((Long)obj);
-                            } else if ( obj instanceof Double ) {
-                                generator.write((Double)obj);
-                            }
-                        } else {
-                            generator.write(obj.toString());
-                        }
-                    }
-
-                    generator.writeEnd();
-                } else {
-                    if ( typePostFix == null ) {
-                        if ( val instanceof String ) {
-                            generator.write(name, (String)val);
-                        } else if ( val instanceof Boolean ) {
-                            generator.write(name, (Boolean)val);
-                        } else if ( val instanceof Long ) {
-                            generator.write(name, (Long)val);
-                        } else if ( val instanceof Double ) {
-                            generator.write(name, (Double)val);
-                        }
-                    } else {
-                        generator.write(name + typePostFix, val.toString());
-                    }
-                }
-            }
-
+            ConfigurationJSONWriter.writeConfiguration(generator, cfg.getConfigurationProperties());
             generator.writeEnd();
         }
 
diff --git a/src/main/java/org/apache/sling/feature/io/json/package-info.java b/src/main/java/org/apache/sling/feature/io/json/package-info.java
index 6b84931..54ecf2e 100644
--- a/src/main/java/org/apache/sling/feature/io/json/package-info.java
+++ b/src/main/java/org/apache/sling/feature/io/json/package-info.java
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-@org.osgi.annotation.versioning.Version("1.0.0")
+@org.osgi.annotation.versioning.Version("1.1.0")
 package org.apache.sling.feature.io.json;
 
 
diff --git a/src/main/java/org/apache/sling/feature/io/package-info.java b/src/main/java/org/apache/sling/feature/io/package-info.java
index 25e2f46..81fdbf9 100644
--- a/src/main/java/org/apache/sling/feature/io/package-info.java
+++ b/src/main/java/org/apache/sling/feature/io/package-info.java
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-@org.osgi.annotation.versioning.Version("1.0.0")
+@org.osgi.annotation.versioning.Version("1.1.0")
 package org.apache.sling.feature.io;
 
 
diff --git a/src/test/java/org/apache/sling/feature/io/json/ConfigurationJSONWriterTest.java b/src/test/java/org/apache/sling/feature/io/json/ConfigurationJSONWriterTest.java
new file mode 100644
index 0000000..13a7b37
--- /dev/null
+++ b/src/test/java/org/apache/sling/feature/io/json/ConfigurationJSONWriterTest.java
@@ -0,0 +1,196 @@
+/*
+ * 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.sling.feature.io.json;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Dictionary;
+import java.util.Enumeration;
+import java.util.Hashtable;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.felix.configurator.impl.json.JSONUtil;
+import org.apache.felix.configurator.impl.json.TypeConverter;
+import org.apache.felix.configurator.impl.model.ConfigurationFile;
+import org.hamcrest.Description;
+import org.hamcrest.TypeSafeDiagnosingMatcher;
+import org.hamcrest.TypeSafeMatcher;
+import org.hamcrest.core.Every;
+import org.junit.Assert;
+import org.junit.Test;
+import org.osgi.util.converter.Converter;
+import org.osgi.util.converter.Converters;
+import org.osgi.util.converter.TypeReference;
+
+public class ConfigurationJSONWriterTest {
+
+    @Test
+    public void testConfigurationWriteReadRoundtrip() throws IOException {
+        Dictionary<String, Object> props = new Hashtable<>();
+        props.put("Integer-simple", 1);
+        props.put("Integer-array", new Integer[]{1,2});
+        props.put("Integer-list", Arrays.asList(1, 2));
+        props.put("int-array", new int[]{1,2});
+        props.put("Long-simple", 1);
+        props.put("Long-array", new Long[]{1L,2L});
+        props.put("Long-list", Arrays.asList(1l, 2l));
+        props.put("long-array", new long[]{1,2});
+        props.put("Boolean-simple", Boolean.TRUE);
+        props.put("Boolean-array", new Boolean[] {Boolean.TRUE, Boolean.FALSE});
+        props.put("Boolean-list", Arrays.asList(Boolean.TRUE, Boolean.FALSE));
+        props.put("bool-array", new boolean[] {true, false});
+        props.put("Float-simple", 1.0d);
+        props.put("Float-array", new Float[]{1.0f, 2.0f});
+        props.put("Float-list", Arrays.asList(1.0f, 2.0f));
+        props.put("float-array", new float[]{1.0f,2.0f});
+        props.put("Double-simple", 1.0d);
+        props.put("Double-array", new Double[]{1.0d,2.0d});
+        props.put("Double-list", Arrays.asList(1.0d, 2.0d));
+        props.put("double-array", new double[]{1.0d,2.0d});
+        props.put("Byte-simple", new Byte((byte)1));
+        props.put("Byte-array", new Byte[]{1,2});
+        props.put("Byte-list", Arrays.asList((byte)1, (byte)2));
+        props.put("byte-array", new byte[]{1,2});
+        props.put("Short-simple", new Short((short) 1));
+        props.put("Short-array", new Short[]{1,2});
+        props.put("Short-list", Arrays.asList((short)1, (short)2));
+        props.put("Short-array", new short[]{1,2});
+        props.put("Character-simple", 1);
+        props.put("Character-array", new Character[]{'a','b'});
+        props.put("Character-list", Arrays.asList('a', 'b'));
+        props.put("char-array", new char[]{'a','b'});
+        props.put("String-simple", "test");
+        props.put("String-array", new String[]{"test1", "test2"});
+        props.put("String-list", Arrays.asList("test1", "test2"));
+        StringWriter writer = new StringWriter();
+        ConfigurationJSONWriter.writeConfiguration(writer, props);
+        writer.close();
+        assertConfigurationJson(writer.toString(), props);
+    }
+
+    protected void assertConfigurationJson(String json, Dictionary<String, Object> expectedProps) throws MalformedURLException {
+        final JSONUtil.Report report = new JSONUtil.Report();
+        StringBuilder sb = new StringBuilder("{ \"");
+        sb.append("myid");
+        sb.append("\" : ");
+        sb.append(json);
+        sb.append("}");
+        final ConfigurationFile configurationFile = JSONUtil.readJSON(new TypeConverter(null),"name", new URL("file:///configtest"),
+                0, sb.toString(), report);
+        if ( !report.errors.isEmpty() || !report.warnings.isEmpty() ) {
+            Assert.fail("JSON is not the right format: \nErrors: " + StringUtils.join(report.errors) + "\nWarnings: " + StringUtils.join(report.warnings));
+        }
+        // convert to maps for easier comparison
+        Converter converter = Converters.standardConverter();
+        Map<String, Object> expectedPropsMap = converter.convert(expectedProps).to(new TypeReference<Map<String,Object>>(){});
+        Map<String, Object> actualPropsMap = converter.convert(configurationFile.getConfigurations().get(0).getProperties()).to(new TypeReference<Map<String,Object>>(){});
+        Assert.assertThat(actualPropsMap.entrySet(), Every.everyItem(new MapEntryMatcher<>(expectedPropsMap)));
+    }
+    
+    public static class MapEntryMatcher<K, V> extends TypeSafeDiagnosingMatcher<Map.Entry<K, V>> {
+
+        private final Map<K,V> expectedMap;
+
+        public MapEntryMatcher(Map<K, V> expectedMap) {
+            this.expectedMap = expectedMap;
+        }
+
+        @Override
+        public void describeTo(Description description) {
+            description.appendText("contained in the expected map");
+        }
+
+        
+        @Override
+        protected boolean matchesSafely(Map.Entry<K, V> item, Description description) {
+            if (expectedMap.get(item.getKey()) == null){
+                description.appendText("key '" + item.getKey() + "' is not present");
+                return false;
+            } else {
+                boolean isEqual;
+                if (item.getValue().getClass().isArray()) {
+                    isEqual = Objects.deepEquals(expectedMap.get(item.getKey()), item.getValue());
+                    
+                } else {
+                    isEqual = expectedMap.get(item.getKey()).equals(item.getValue());
+                }
+                if (!isEqual) {
+                    description.appendText("has the wrong value for key '" + item.getKey() + "': Expected=" + expectedMap.get(item.getKey()) + " (" + expectedMap.get(item.getKey()).getClass() + ")" + ", Actual=" + item.getValue() + " (" + item.getValue().getClass() + ")");
+                }
+                return isEqual;
+            }
+        }
+    }
+    
+    public final static class DictionaryMatcher extends TypeSafeMatcher<Dictionary<String, Object>> {
+        
+        // internally use maps
+        private final Dictionary<String, Object> expectedDictionary;
+
+        public DictionaryMatcher(Dictionary<String, Object> dictionary) {
+            this.expectedDictionary = dictionary;
+        }
+        
+        @Override
+        public void describeTo(Description description) {
+            description.appendText("Dictionary with items: ").appendValueList("", ",", "", toString(expectedDictionary));
+        }
+
+        
+        @Override
+        protected void describeMismatchSafely(Dictionary<String, Object> item, Description mismatchDescription) {
+            mismatchDescription.appendText("was Dictionary with items: ").appendValueList("", ",", "", toString(item));
+        }
+
+        static Iterable<String> toString(Dictionary<String, Object> dictionary) {
+            List<String> itemList = new LinkedList<String>();
+            Enumeration<String> e = dictionary.keys();
+            while (e.hasMoreElements()) {
+                final String key = e.nextElement();
+                Object value = dictionary.get(key);
+                // for arrays expand values
+                final String type = value.getClass().getSimpleName();
+                if (value.getClass().isArray()) {
+                    value = ArrayUtils.toString(value);
+                }
+                StringBuilder sb = new StringBuilder();
+                sb.append(key).append(":");
+                sb.append(value);
+                sb.append("(").append(type).append(")");
+                sb.append(", ");
+                itemList.add(sb.toString());
+            }
+            return itemList;
+        }
+
+        @Override
+        protected boolean matchesSafely(Dictionary<String, Object> item) {
+            // use a map for comparison
+            //expectedDictionary.x
+            return expectedDictionary.equals(item);
+        }
+        
+    }
+}