SLING-6872 JCR Content Parser: Support tick as well as double quote when parsing JSON

git-svn-id: https://svn.apache.org/repos/asf/sling/trunk@1795961 13f79535-47bb-0310-9956-ffa450edef68
diff --git a/pom.xml b/pom.xml
index e889134..5df4e2f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -78,6 +78,12 @@
             <scope>compile</scope>
         </dependency>
         <dependency>
+            <groupId>commons-io</groupId>
+            <artifactId>commons-io</artifactId>
+            <version>2.4</version>
+            <scope>compile</scope>
+        </dependency>
+        <dependency>
             <groupId>org.apache.johnzon</groupId>
             <artifactId>johnzon-core</artifactId>
             <version>1.0.0</version>
diff --git a/src/main/java/org/apache/sling/jcr/contentparser/JsonParserFeature.java b/src/main/java/org/apache/sling/jcr/contentparser/JsonParserFeature.java
new file mode 100644
index 0000000..59c5a23
--- /dev/null
+++ b/src/main/java/org/apache/sling/jcr/contentparser/JsonParserFeature.java
@@ -0,0 +1,36 @@
+/*
+ * 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.jcr.contentparser;
+
+/**
+ * Feature flags for parsing JSON files.
+ */
+public enum JsonParserFeature {
+
+    /**
+     * Support comments (&#47;* ... *&#47;) in JSON files.
+     */
+    COMMENTS,
+    
+    /**
+     * Support ticks (') additional to double quotes (") as quoting symbol for JSON names and strings. 
+     */
+    QUOTE_TICK
+    
+}
diff --git a/src/main/java/org/apache/sling/jcr/contentparser/ParserOptions.java b/src/main/java/org/apache/sling/jcr/contentparser/ParserOptions.java
index e8c3939..f878745 100644
--- a/src/main/java/org/apache/sling/jcr/contentparser/ParserOptions.java
+++ b/src/main/java/org/apache/sling/jcr/contentparser/ParserOptions.java
@@ -20,6 +20,7 @@
 
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.HashSet;
 import java.util.Set;
 
@@ -47,11 +48,18 @@
             "jcr:uri:"
           )));
     
+    /**
+     * List of JSON parser features activated by default.
+     */
+    public static final EnumSet<JsonParserFeature> DEFAULT_JSON_PARSER_FEATURES
+        = EnumSet.of(JsonParserFeature.COMMENTS, JsonParserFeature.QUOTE_TICK);
+    
     private String defaultPrimaryType = DEFAULT_PRIMARY_TYPE;
     private boolean detectCalendarValues;
     private Set<String> ignorePropertyNames;
     private Set<String> ignoreResourceNames;
     private Set<String> removePropertyNamePrefixes = DEFAULT_REMOVE_PROPERTY_NAME_PREFIXES;
+    private EnumSet<JsonParserFeature> jsonParserFeatures = DEFAULT_JSON_PARSER_FEATURES;
     
     /**
      * Default "jcr:primaryType" property for resources that have no explicit value for this value.
@@ -121,4 +129,21 @@
         return removePropertyNamePrefixes;
     }
     
+    /**
+     * Set set of features the JSON parser should apply when parsing files. 
+     * @param value JSON parser features
+     * @return this
+     */
+    public ParserOptions jsonParserFeatures(EnumSet<JsonParserFeature> value) {
+        this.jsonParserFeatures = value;
+        return this;
+    }
+    public ParserOptions jsonParserFeatures(JsonParserFeature... value) {
+        this.jsonParserFeatures = EnumSet.copyOf(Arrays.asList(value));
+        return this;
+    }
+    public EnumSet<JsonParserFeature> getJsonParserFeatures() {
+        return jsonParserFeatures;
+    }
+    
 }
diff --git a/src/main/java/org/apache/sling/jcr/contentparser/impl/JsonContentParser.java b/src/main/java/org/apache/sling/jcr/contentparser/impl/JsonContentParser.java
index 093fbec..054be85 100644
--- a/src/main/java/org/apache/sling/jcr/contentparser/impl/JsonContentParser.java
+++ b/src/main/java/org/apache/sling/jcr/contentparser/impl/JsonContentParser.java
@@ -20,6 +20,7 @@
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.StringReader;
 import java.util.Calendar;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
@@ -35,8 +36,11 @@
 import javax.json.JsonValue;
 import javax.json.stream.JsonParsingException;
 
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.CharEncoding;
 import org.apache.sling.jcr.contentparser.ContentHandler;
 import org.apache.sling.jcr.contentparser.ContentParser;
+import org.apache.sling.jcr.contentparser.JsonParserFeature;
 import org.apache.sling.jcr.contentparser.ParseException;
 import org.apache.sling.jcr.contentparser.ParserOptions;
 
@@ -54,21 +58,56 @@
      */
     private final JsonReaderFactory jsonReaderFactory;
     
+    private final boolean jsonQuoteTickets;
+    
     public JsonContentParser(ParserOptions options) {
         this.helper = new ParserHelper(options);
-        // allow comments in JSON files
+        
         Map<String,Object> jsonReaderFactoryConfig = new HashMap<>();
-        jsonReaderFactoryConfig.put("org.apache.johnzon.supports-comments", true);
+        
+        // allow comments in JSON files?
+        if (options.getJsonParserFeatures().contains(JsonParserFeature.COMMENTS)) {
+            jsonReaderFactoryConfig.put("org.apache.johnzon.supports-comments", true);
+        }
+        jsonQuoteTickets = options.getJsonParserFeatures().contains(JsonParserFeature.QUOTE_TICK);
+        
         jsonReaderFactory = Json.createReaderFactory(jsonReaderFactoryConfig);
     }
     
     @Override
     public void parse(ContentHandler handler, InputStream is) throws IOException, ParseException {
+        parse(handler, toJsonObject(is), "/");
+    }
+    
+    private JsonObject toJsonObject(InputStream is) {
+        if (jsonQuoteTickets) {
+            return toJsonObjectWithJsonTicks(is);
+        }
         try (JsonReader reader = jsonReaderFactory.createReader(is)) {
-            parse(handler, reader.readObject(), "/");
+            return reader.readObject();
         }
         catch (JsonParsingException ex) {
-            throw new ParseException("Error parsing JSON content.", ex);
+            throw new ParseException("Error parsing JSON content: " + ex.getMessage(), ex);
+        }
+    }
+
+    private JsonObject toJsonObjectWithJsonTicks(InputStream is) {
+        String jsonString;
+        try {
+            jsonString = IOUtils.toString(is, CharEncoding.UTF_8);
+        }
+        catch (IOException ex) {
+            throw new ParseException("Error getting JSON string.", ex);
+        }
+        
+        // convert ticks to double quotes
+        jsonString = JsonTicksConverter.tickToDoubleQuote(jsonString);
+        
+        try (JsonReader reader = jsonReaderFactory.createReader(new StringReader(jsonString))) {
+            return reader.readObject();
+        }
+        catch (JsonParsingException ex) {
+            throw new ParseException("Error parsing JSON content: " + ex.getMessage(), ex);
         }
     }
 
diff --git a/src/main/java/org/apache/sling/jcr/contentparser/impl/JsonTicksConverter.java b/src/main/java/org/apache/sling/jcr/contentparser/impl/JsonTicksConverter.java
new file mode 100644
index 0000000..6125599
--- /dev/null
+++ b/src/main/java/org/apache/sling/jcr/contentparser/impl/JsonTicksConverter.java
@@ -0,0 +1,85 @@
+/*
+ * 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.jcr.contentparser.impl;
+
+/**
+ * Converts JSON with ticks to JSON with quotes.
+ * <p>Conversions:</p>
+ * <ul>
+ * <li>Converts ticks ' to " when used as quotation marks for names or string values</li>
+ * <li>Within names or string values quoted with ticks, ticks have to be escaped with <code>\'</code>.
+ *     This escaping sign is removed on the conversion, because in JSON ticks must not be escaped.</li>
+ * <li>Within names or string values quoted with ticks, double quotes may or may not be escaped.
+ *     After the conversion they are always escaped.</li>
+ * </ul>
+ */
+class JsonTicksConverter {
+    
+    static String tickToDoubleQuote(final String input) {
+        final StringBuilder output = new StringBuilder();
+        boolean quoted = false;
+        boolean tickQuoted = false;
+        boolean escaped = false;
+        for (int i = 0, len = input.length(); i < len; i++) {
+            char in = input.charAt(i);
+            if (quoted || tickQuoted) {
+                if (escaped) {
+                    if (in != '\'') {
+                        output.append("\\");
+                    }
+                    escaped = false;
+                }
+                else {
+                    if (in == '"') {
+                        if (quoted) {
+                            quoted = false;
+                        }
+                        else if (tickQuoted) {
+                            output.append("\\");
+                        }
+                    }
+                    else if (in == '\'') {
+                        if (tickQuoted) {
+                            in = '"';
+                            tickQuoted = false;
+                        }
+                    }
+                    else if (in == '\\') {
+                        escaped = true;
+                    }
+                }
+            }
+            else {
+                if (in == '\'') {
+                    in = '"';
+                    tickQuoted = true;
+                }
+                else if (in == '"') {
+                    quoted = true;
+                }
+            }
+            if (in == '\\') {
+                continue;
+            }
+            output.append(in);
+        }
+        return output.toString();
+    }
+
+}
diff --git a/src/main/java/org/apache/sling/jcr/contentparser/package-info.java b/src/main/java/org/apache/sling/jcr/contentparser/package-info.java
index 771f212..5cf33d1 100644
--- a/src/main/java/org/apache/sling/jcr/contentparser/package-info.java
+++ b/src/main/java/org/apache/sling/jcr/contentparser/package-info.java
@@ -19,5 +19,5 @@
 /**
  * Parser for repository content serialized e.g. as JSON or JCR XML.
  */
-@org.osgi.annotation.versioning.Version("1.1.0")
+@org.osgi.annotation.versioning.Version("1.2.0")
 package org.apache.sling.jcr.contentparser;
diff --git a/src/test/java/org/apache/sling/jcr/contentparser/impl/JsonContentParserTest.java b/src/test/java/org/apache/sling/jcr/contentparser/impl/JsonContentParserTest.java
index 706cb6e..b8f3598 100644
--- a/src/test/java/org/apache/sling/jcr/contentparser/impl/JsonContentParserTest.java
+++ b/src/test/java/org/apache/sling/jcr/contentparser/impl/JsonContentParserTest.java
@@ -27,12 +27,14 @@
 import java.io.File;
 import java.math.BigDecimal;
 import java.util.Calendar;
+import java.util.EnumSet;
 import java.util.Map;
 import java.util.TimeZone;
 
 import org.apache.sling.jcr.contentparser.ContentParser;
 import org.apache.sling.jcr.contentparser.ContentParserFactory;
 import org.apache.sling.jcr.contentparser.ContentType;
+import org.apache.sling.jcr.contentparser.JsonParserFeature;
 import org.apache.sling.jcr.contentparser.ParseException;
 import org.apache.sling.jcr.contentparser.ParserOptions;
 import org.apache.sling.jcr.contentparser.impl.mapsupport.ContentElement;
@@ -168,4 +170,11 @@
         assertNull(invalidChild);
     }
 
+    @Test(expected = ParseException.class)
+    public void testFailsWithoutCommentsEnabled() throws Exception {
+        ContentParser underTest = ContentParserFactory.create(ContentType.JSON,
+                new ParserOptions().jsonParserFeatures(EnumSet.noneOf(JsonParserFeature.class)));
+        parse(underTest, file);
+    }
+
 }
diff --git a/src/test/java/org/apache/sling/jcr/contentparser/impl/JsonContentParserTicksTest.java b/src/test/java/org/apache/sling/jcr/contentparser/impl/JsonContentParserTicksTest.java
new file mode 100644
index 0000000..1ba5e62
--- /dev/null
+++ b/src/test/java/org/apache/sling/jcr/contentparser/impl/JsonContentParserTicksTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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.jcr.contentparser.impl;
+
+import static org.apache.sling.jcr.contentparser.impl.TestUtils.parse;
+import static org.junit.Assert.assertEquals;
+
+import java.util.EnumSet;
+import java.util.Map;
+
+import org.apache.sling.jcr.contentparser.ContentParser;
+import org.apache.sling.jcr.contentparser.ContentParserFactory;
+import org.apache.sling.jcr.contentparser.ContentType;
+import org.apache.sling.jcr.contentparser.JsonParserFeature;
+import org.apache.sling.jcr.contentparser.ParseException;
+import org.apache.sling.jcr.contentparser.ParserOptions;
+import org.apache.sling.jcr.contentparser.impl.mapsupport.ContentElement;
+import org.junit.Before;
+import org.junit.Test;
+
+public class JsonContentParserTicksTest {
+    
+    private ContentParser underTest;
+    
+    @Before
+    public void setUp() {
+        underTest = ContentParserFactory.create(ContentType.JSON,
+                new ParserOptions().jsonParserFeatures(JsonParserFeature.QUOTE_TICK));
+    }
+
+    @Test
+    public void testJsonWithTicks() throws Exception {
+        ContentElement content = parse(underTest, "{'prop1':'value1','prop2':123,'obj':{'prop3':'value2'}}");
+
+        Map<String, Object> props = content.getProperties();
+        assertEquals("value1", props.get("prop1"));
+        assertEquals(123L, props.get("prop2"));
+        assertEquals("value2", content.getChild("obj").getProperties().get("prop3"));
+    }
+
+    @Test
+    public void testJsonWithTicksMixed() throws Exception {
+        ContentElement content = parse(underTest, "{\"prop1\":'value1','prop2':123,'obj':{'prop3':\"value2\"}}");
+
+        Map<String, Object> props = content.getProperties();
+        assertEquals("value1", props.get("prop1"));
+        assertEquals(123L, props.get("prop2"));
+        assertEquals("value2", content.getChild("obj").getProperties().get("prop3"));
+    }
+
+    @Test
+    public void testTicksDoubleQuotesInDoubleQuotes() throws Exception {
+        ContentElement content = parse(underTest, "{\"prop1\":\"'\\\"\'\\\"\"}");
+
+        Map<String, Object> props = content.getProperties();
+        assertEquals("'\"'\"", props.get("prop1"));
+    }
+
+    @Test
+    public void testTicksDoubleQuotesInTicks() throws Exception {
+        ContentElement content = parse(underTest, "{'prop1':'\\'\\\"\\\'\\\"'}");
+
+        Map<String, Object> props = content.getProperties();
+        assertEquals("'\"'\"", props.get("prop1"));
+    }
+
+    @Test
+    public void testWithUtf8Escaped() throws Exception {
+        ContentElement content = parse(underTest, "{\"prop1\":\"\\u03A9\\u03A6\\u00A5\"}");
+
+        Map<String, Object> props = content.getProperties();
+        assertEquals("\u03A9\u03A6\u00A5", props.get("prop1"));
+    }
+
+    @Test
+    public void testWithTicksUtf8Escaped() throws Exception {
+        ContentElement content = parse(underTest, "{'prop1':'\\u03A9\\u03A6\\u00A5'}");
+
+        Map<String, Object> props = content.getProperties();
+        assertEquals("\u03A9\u03A6\u00A5", props.get("prop1"));
+    }
+
+    @Test(expected = ParseException.class)
+    public void testFailsWihtoutFeatureEnabled() throws Exception {
+        underTest = ContentParserFactory.create(ContentType.JSON,
+                new ParserOptions().jsonParserFeatures(EnumSet.noneOf(JsonParserFeature.class)));
+        parse(underTest, "{'prop1':'value1','prop2':123,'obj':{'prop3':'value2'}}");
+    }
+
+}
diff --git a/src/test/java/org/apache/sling/jcr/contentparser/impl/JsonTicksConverterTest.java b/src/test/java/org/apache/sling/jcr/contentparser/impl/JsonTicksConverterTest.java
new file mode 100644
index 0000000..836d1a8
--- /dev/null
+++ b/src/test/java/org/apache/sling/jcr/contentparser/impl/JsonTicksConverterTest.java
@@ -0,0 +1,61 @@
+/*
+ * 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.jcr.contentparser.impl;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+import static org.apache.sling.jcr.contentparser.impl.JsonTicksConverter.*;
+
+public class JsonTicksConverterTest {
+
+    @Test
+    public void testNoConvert() {
+        assertEquals("{\"p\":\"v\"}", tickToDoubleQuote("{\"p\":\"v\"}"));
+    }
+
+    @Test
+    public void testTickToQuote() {
+        assertEquals("{\"p\":\"v\"}", tickToDoubleQuote("{'p':\"v\"}"));
+    }
+
+    @Test
+    public void testTickToQuoteMixed() {
+        assertEquals("{\"p\":\"v\"}", tickToDoubleQuote("{'p':\"v\"}"));
+        assertEquals("{\"p\":\"v\"}", tickToDoubleQuote("{\"p\":'v'}"));
+    }
+
+    @Test
+    public void testTicksDoubleQuotesInDoubleQuotes() {
+        assertEquals("{\"p\":\"'\\\"'\\\"\"}", tickToDoubleQuote("{\"p\":\"'\\\"'\\\"\"}"));
+    }
+
+    @Test
+    public void testTicksDoubleQuotesInTicks() {
+        assertEquals("{\"p\":\"'\\\"'\\\"\"}", tickToDoubleQuote("{\"p\":'\\'\\\"\\'\\\"'}"));
+        assertEquals("{\"p\":\"'\\\"'\\\"\"}", tickToDoubleQuote("{\"p\":'\\'\"\\'\"'}"));
+    }
+
+    @Test
+    public void testTickToQuoteWithUtf8Escaped() {
+        assertEquals("{\"p\":\"\\u03A9\\u03A6\\u00A5\"}", tickToDoubleQuote("{'p':\"\\u03A9\\u03A6\\u00A5\"}"));
+    }
+
+}
diff --git a/src/test/java/org/apache/sling/jcr/contentparser/impl/TestUtils.java b/src/test/java/org/apache/sling/jcr/contentparser/impl/TestUtils.java
index be395b9..0908a47 100644
--- a/src/test/java/org/apache/sling/jcr/contentparser/impl/TestUtils.java
+++ b/src/test/java/org/apache/sling/jcr/contentparser/impl/TestUtils.java
@@ -19,10 +19,12 @@
 package org.apache.sling.jcr.contentparser.impl;
 
 import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
 
+import org.apache.commons.lang3.CharEncoding;
 import org.apache.sling.jcr.contentparser.ContentParser;
 import org.apache.sling.jcr.contentparser.impl.mapsupport.ContentElement;
 import org.apache.sling.jcr.contentparser.impl.mapsupport.ContentElementHandler;
@@ -42,4 +44,12 @@
         }
     }
     
+    public static ContentElement parse(ContentParser contentParser, String jsonContent) throws IOException {
+        try (ByteArrayInputStream is = new ByteArrayInputStream(jsonContent.getBytes(CharEncoding.UTF_8))) {
+            ContentElementHandler handler = new ContentElementHandler();
+            contentParser.parse(handler, is);
+            return handler.getRoot();
+        }
+    }
+    
 }