From 1ba417140554dbd57602a1f4b453fc42363cf394 Mon Sep 17 00:00:00 2001 From: Jesse Wilson Date: Fri, 6 Aug 2010 16:08:59 -0700 Subject: [PATCH] Adding JsonReader.setLenient() to handle malformed JSON strings. Also replacing setIndentSpaces() with a more general purpose method, setIndent(). Change-Id: I64fbe4901aec23de5392362c1d40b77bc2b5566b --- api/current.xml | 23 +- core/java/android/util/JsonReader.java | 164 +++++++---- core/java/android/util/JsonWriter.java | 29 +- .../src/android/util/JsonReaderTest.java | 268 ++++++++++++++++++ .../src/android/util/JsonWriterTest.java | 4 +- 5 files changed, 405 insertions(+), 83 deletions(-) diff --git a/api/current.xml b/api/current.xml index 064b998dbf054..4b4075874fc71 100644 --- a/api/current.xml +++ b/api/current.xml @@ -180354,6 +180354,19 @@ + + + + + + - - + - + @@ -274187,7 +274202,7 @@ deprecated="not deprecated" visibility="public" > - + diff --git a/core/java/android/util/JsonReader.java b/core/java/android/util/JsonReader.java index e47be0d6d28c7..a2dffce059ff5 100644 --- a/core/java/android/util/JsonReader.java +++ b/core/java/android/util/JsonReader.java @@ -172,6 +172,9 @@ public final class JsonReader implements Closeable { /** The input JSON. */ private final Reader in; + /** True to accept non-spec compliant JSON */ + private boolean lenient = false; + /** * Use a manual buffer to easily read and unread upcoming characters, and * also so we can create strings without an intermediate StringBuilder. @@ -207,9 +210,6 @@ public final class JsonReader implements Closeable { /** The text of the next literal value. */ private String value; - // TODO: make this parser strict and offer an optional lenient mode? - // TODO: document how this reader is non-strict - /** * Creates a new instance that reads a JSON-encoded stream from {@code in}. */ @@ -220,6 +220,31 @@ public final class JsonReader implements Closeable { this.in = in; } + /** + * Configure this parser to be be liberal in what it accepts. By default, + * this parser is strict and only accepts JSON as specified by RFC 4627. Setting the + * parser to lenient causes it to ignore the following syntax errors: + * + *
    + *
  • End of line comments starting with {@code //} or {@code #} and + * ending with a newline character. + *
  • C-style comments starting with {@code /*} and ending with + * {@code *}{@code /}. Such comments may not be nested. + *
  • Names that are unquoted or {@code 'single quoted'}. + *
  • Strings that are unquoted or {@code 'single quoted'}. + *
  • Array elements separated by {@code ;} instead of {@code ,}. + *
  • Unnecessary array separators. These are interpreted as if null + * was the omitted value. + *
  • Names and values separated by {@code =} or {@code =>} instead of + * {@code :}. + *
  • Name/value pairs separated by {@code ;} instead of {@code ,}. + *
+ */ + public void setLenient(boolean lenient) { + this.lenient = lenient; + } + /** * Consumes the next token from the JSON stream and asserts that it is the * beginning of a new array. @@ -253,11 +278,12 @@ public final class JsonReader implements Closeable { } /** - * Consumes {@code token}. + * Consumes {@code expected}. */ - private void expect(JsonToken token) throws IOException { - if (quickPeek() != token) { - throw new IllegalStateException("Expected " + token + " but was " + peek()); + private void expect(JsonToken expected) throws IOException { + quickPeek(); + if (token != expected) { + throw new IllegalStateException("Expected " + expected + " but was " + peek()); } advance(); } @@ -266,8 +292,8 @@ public final class JsonReader implements Closeable { * Returns true if the current array or object has another element. */ public boolean hasNext() throws IOException { - JsonToken peek = quickPeek(); - return peek != JsonToken.END_OBJECT && peek != JsonToken.END_ARRAY; + quickPeek(); + return token != JsonToken.END_OBJECT && token != JsonToken.END_ARRAY; } /** @@ -285,11 +311,8 @@ public final class JsonReader implements Closeable { /** * Ensures that a token is ready. After this call either {@code token} or - * {@code value} will be non-null. - * - * @return the type of the next token, of {@code null} if it is unknown. For - * a definitive result, use {@link #peek()} which decodes the token - * type. + * {@code value} will be non-null. To ensure {@code token} has a definitive + * value, use {@link #peek()} */ private JsonToken quickPeek() throws IOException { if (hasToken) { @@ -347,7 +370,8 @@ public final class JsonReader implements Closeable { * name. */ public String nextName() throws IOException { - if (quickPeek() != JsonToken.NAME) { + quickPeek(); + if (token != JsonToken.NAME) { throw new IllegalStateException("Expected a name but was " + peek()); } String result = name; @@ -364,8 +388,8 @@ public final class JsonReader implements Closeable { * this reader is closed. */ public String nextString() throws IOException { - JsonToken peek = peek(); - if (value == null || (peek != JsonToken.STRING && peek != JsonToken.NUMBER)) { + peek(); + if (value == null || (token != JsonToken.STRING && token != JsonToken.NUMBER)) { throw new IllegalStateException("Expected a string but was " + peek()); } @@ -382,8 +406,8 @@ public final class JsonReader implements Closeable { * this reader is closed. */ public boolean nextBoolean() throws IOException { - JsonToken peek = quickPeek(); - if (value == null || peek == JsonToken.STRING) { + quickPeek(); + if (value == null || token == JsonToken.STRING) { throw new IllegalStateException("Expected a boolean but was " + peek()); } @@ -408,8 +432,8 @@ public final class JsonReader implements Closeable { * reader is closed. */ public void nextNull() throws IOException { - JsonToken peek = quickPeek(); - if (value == null || peek == JsonToken.STRING) { + quickPeek(); + if (value == null || token == JsonToken.STRING) { throw new IllegalStateException("Expected null but was " + peek()); } @@ -570,36 +594,43 @@ public final class JsonReader implements Closeable { private JsonToken nextInArray(boolean firstElement) throws IOException { if (firstElement) { - switch (nextNonWhitespace()) { - case ']': - pop(); - hasToken = true; - return token = JsonToken.END_ARRAY; - case ',': - case ';': - /* a separator without a value first means "null". */ - // TODO: forbid this in strict mode - hasToken = true; - return token = JsonToken.NULL; - default: - replaceTop(JsonScope.NONEMPTY_ARRAY); - pos--; - } + replaceTop(JsonScope.NONEMPTY_ARRAY); } else { + /* Look for a comma before each element after the first element. */ switch (nextNonWhitespace()) { case ']': pop(); hasToken = true; return token = JsonToken.END_ARRAY; - case ',': case ';': + checkLenient(); // fall-through + case ',': break; default: throw syntaxError("Unterminated array"); } } - return nextValue(); + switch (nextNonWhitespace()) { + case ']': + if (firstElement) { + pop(); + hasToken = true; + return token = JsonToken.END_ARRAY; + } + // fall-through to handle ",]" + case ';': + case ',': + /* In lenient mode, a 0-length literal means 'null' */ + checkLenient(); + pos--; + hasToken = true; + value = "null"; + return token = JsonToken.NULL; + default: + pos--; + return nextValue(); + } } private JsonToken nextInObject(boolean firstElement) throws IOException { @@ -636,10 +667,12 @@ public final class JsonReader implements Closeable { int quote = nextNonWhitespace(); switch (quote) { case '\'': + checkLenient(); // fall-through case '"': name = nextString((char) quote); break; default: + checkLenient(); pos--; name = nextLiteral(); if (name.isEmpty()) { @@ -653,19 +686,21 @@ public final class JsonReader implements Closeable { } private JsonToken objectValue() throws IOException { - // TODO: accept only ":" in strict mode - /* - * Read the name/value separator. Usually a colon ':', an equals sign - * '=', or an arrow "=>". The last two are bogus but we include them - * because that's what org.json does. + * Read the name/value separator. Usually a colon ':'. In lenient mode + * we also accept an equals sign '=', or an arrow "=>". */ - int separator = nextNonWhitespace(); - if (separator != ':' && separator != '=') { - throw syntaxError("Expected ':'"); - } - if (separator == '=' && (pos < limit || fillBuffer(1)) && buffer[pos] == '>') { - pos++; + switch (nextNonWhitespace()) { + case ':': + break; + case '=': + checkLenient(); + if ((pos < limit || fillBuffer(1)) && buffer[pos] == '>') { + pos++; + } + break; + default: + throw syntaxError("Expected ':'"); } replaceTop(JsonScope.NONEMPTY_OBJECT); @@ -686,6 +721,7 @@ public final class JsonReader implements Closeable { return token = JsonToken.BEGIN_ARRAY; case '\'': + checkLenient(); // fall-through case '"': value = nextString((char) c); hasToken = true; @@ -722,8 +758,6 @@ public final class JsonReader implements Closeable { } private int nextNonWhitespace() throws IOException { - // TODO: no comments in strict mode - while (pos < limit || fillBuffer(1)) { int c = buffer[pos++]; switch (c) { @@ -738,6 +772,7 @@ public final class JsonReader implements Closeable { return c; } + checkLenient(); char peek = buffer[pos]; switch (peek) { case '*': @@ -765,6 +800,7 @@ public final class JsonReader implements Closeable { * specify this behaviour, but it's required to parse * existing documents. See http://b/2571423. */ + checkLenient(); skipToEndOfLine(); continue; @@ -776,6 +812,12 @@ public final class JsonReader implements Closeable { throw syntaxError("End of input"); } + private void checkLenient() throws IOException { + if (!lenient) { + throw syntaxError("Use JsonReader.setLenient(true) to accept malformed JSON"); + } + } + /** * Advances the position until after the next newline character. If the line * is terminated by "\r\n", the '\n' must be consumed as whitespace by the @@ -853,9 +895,6 @@ public final class JsonReader implements Closeable { * does not consume the delimiter character. */ private String nextLiteral() throws IOException { - // TODO: use a much smaller set of permitted literal characters in strict mode; - // these characters are derived from org.json's lenient mode - StringBuilder builder = null; do { /* the index of the first character not yet appended to the builder. */ @@ -863,17 +902,19 @@ public final class JsonReader implements Closeable { while (pos < limit) { int c = buffer[pos++]; switch (c) { + case '/': + case '\\': + case ';': + case '#': + case '=': + checkLenient(); // fall-through + case '{': case '}': case '[': case ']': - case '/': - case '\\': case ':': - case '=': case ',': - case ';': - case '#': case ' ': case '\t': case '\f': @@ -965,7 +1006,7 @@ public final class JsonReader implements Closeable { /** * Assigns {@code nextToken} based on the value of {@code nextValue}. */ - private void decodeLiteral() { + private void decodeLiteral() throws IOException { if (value.equalsIgnoreCase("null")) { token = JsonToken.NULL; } else if (value.equalsIgnoreCase("true") || value.equalsIgnoreCase("false")) { @@ -975,7 +1016,8 @@ public final class JsonReader implements Closeable { Double.parseDouble(value); // this work could potentially be cached token = JsonToken.NUMBER; } catch (NumberFormatException ignored) { - /* an unquoted string. This document is not well-formed! */ + // this must be an unquoted string + checkLenient(); token = JsonToken.STRING; } } diff --git a/core/java/android/util/JsonWriter.java b/core/java/android/util/JsonWriter.java index 913b04784d98c..fecc1c8737712 100644 --- a/core/java/android/util/JsonWriter.java +++ b/core/java/android/util/JsonWriter.java @@ -16,10 +16,10 @@ package android.util; +import java.io.Closeable; import java.io.IOException; import java.io.Writer; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; /** @@ -117,7 +117,7 @@ import java.util.List; * Instances of this class are not thread safe. Calls that would result in a * malformed JSON string will fail with an {@link IllegalStateException}. */ -public final class JsonWriter { +public final class JsonWriter implements Closeable { /** The output data, containing at most one top-level array or object. */ private final Writer out; @@ -151,23 +151,20 @@ public final class JsonWriter { } /** - * Sets the number of spaces to indent each line in the encoded document. - * If {@code indent == 0} the encoded document will be compact. If {@code - * indent > 0}, the encoded document will be more human-readable. + * Sets the indentation string to be repeated for each level of indentation + * in the encoded document. If {@code indent.isEmpty()} the encoded document + * will be compact. Otherwise the encoded document will be more + * human-readable. + * + * @param indent a string containing only whitespace. */ - public void setIndentSpaces(int indent) { - if (indent < 0) { - throw new IllegalArgumentException("indent < 0"); - } - - if (indent > 0) { - char[] indentChars = new char[indent]; - Arrays.fill(indentChars, ' '); - this.indent = new String(indentChars); - this.separator = ": "; - } else { + public void setIndent(String indent) { + if (indent.isEmpty()) { this.indent = null; this.separator = ":"; + } else { + this.indent = indent; + this.separator = ": "; } } diff --git a/core/tests/coretests/src/android/util/JsonReaderTest.java b/core/tests/coretests/src/android/util/JsonReaderTest.java index b0cfb7bf4364b..ced9310d5d5db 100644 --- a/core/tests/coretests/src/android/util/JsonReaderTest.java +++ b/core/tests/coretests/src/android/util/JsonReaderTest.java @@ -32,6 +32,14 @@ public final class JsonReaderTest extends TestCase { assertEquals(JsonToken.END_DOCUMENT, reader.peek()); } + public void testReadEmptyArray() throws IOException { + JsonReader reader = new JsonReader(new StringReader("[]")); + reader.beginArray(); + assertFalse(reader.hasNext()); + reader.endArray(); + assertEquals(JsonToken.END_DOCUMENT, reader.peek()); + } + public void testReadObject() throws IOException { JsonReader reader = new JsonReader(new StringReader( "{\"a\": \"android\", \"b\": \"banana\"}")); @@ -44,6 +52,14 @@ public final class JsonReaderTest extends TestCase { assertEquals(JsonToken.END_DOCUMENT, reader.peek()); } + public void testReadEmptyObject() throws IOException { + JsonReader reader = new JsonReader(new StringReader("{}")); + reader.beginObject(); + assertFalse(reader.hasNext()); + reader.endObject(); + assertEquals(JsonToken.END_DOCUMENT, reader.peek()); + } + public void testSkipObject() throws IOException { JsonReader reader = new JsonReader(new StringReader( "{\"a\": { \"c\": [], \"d\": [true, true, {}] }, \"b\": \"banana\"}")); @@ -412,4 +428,256 @@ public final class JsonReaderTest extends TestCase { } catch (IllegalStateException expected) { } } + + public void testStrictNameValueSeparator() throws IOException { + JsonReader reader = new JsonReader(new StringReader("{\"a\"=true}")); + reader.beginObject(); + assertEquals("a", reader.nextName()); + try { + reader.nextBoolean(); + fail(); + } catch (IOException expected) { + } + + reader = new JsonReader(new StringReader("{\"a\"=>true}")); + reader.beginObject(); + assertEquals("a", reader.nextName()); + try { + reader.nextBoolean(); + fail(); + } catch (IOException expected) { + } + } + + public void testLenientNameValueSeparator() throws IOException { + JsonReader reader = new JsonReader(new StringReader("{\"a\"=true}")); + reader.setLenient(true); + reader.beginObject(); + assertEquals("a", reader.nextName()); + assertEquals(true, reader.nextBoolean()); + + reader = new JsonReader(new StringReader("{\"a\"=>true}")); + reader.setLenient(true); + reader.beginObject(); + assertEquals("a", reader.nextName()); + assertEquals(true, reader.nextBoolean()); + } + + public void testStrictComments() throws IOException { + JsonReader reader = new JsonReader(new StringReader("[// comment \n true]")); + reader.beginArray(); + try { + reader.nextBoolean(); + fail(); + } catch (IOException expected) { + } + + reader = new JsonReader(new StringReader("[# comment \n true]")); + reader.beginArray(); + try { + reader.nextBoolean(); + fail(); + } catch (IOException expected) { + } + + reader = new JsonReader(new StringReader("[/* comment */ true]")); + reader.beginArray(); + try { + reader.nextBoolean(); + fail(); + } catch (IOException expected) { + } + } + + public void testLenientComments() throws IOException { + JsonReader reader = new JsonReader(new StringReader("[// comment \n true]")); + reader.setLenient(true); + reader.beginArray(); + assertEquals(true, reader.nextBoolean()); + + reader = new JsonReader(new StringReader("[# comment \n true]")); + reader.setLenient(true); + reader.beginArray(); + assertEquals(true, reader.nextBoolean()); + + reader = new JsonReader(new StringReader("[/* comment */ true]")); + reader.setLenient(true); + reader.beginArray(); + assertEquals(true, reader.nextBoolean()); + } + + public void testStrictUnquotedNames() throws IOException { + JsonReader reader = new JsonReader(new StringReader("{a:true}")); + reader.beginObject(); + try { + reader.nextName(); + fail(); + } catch (IOException expected) { + } + } + + public void testLenientUnquotedNames() throws IOException { + JsonReader reader = new JsonReader(new StringReader("{a:true}")); + reader.setLenient(true); + reader.beginObject(); + assertEquals("a", reader.nextName()); + } + + public void testStrictSingleQuotedNames() throws IOException { + JsonReader reader = new JsonReader(new StringReader("{'a':true}")); + reader.beginObject(); + try { + reader.nextName(); + fail(); + } catch (IOException expected) { + } + } + + public void testLenientSingleQuotedNames() throws IOException { + JsonReader reader = new JsonReader(new StringReader("{'a':true}")); + reader.setLenient(true); + reader.beginObject(); + assertEquals("a", reader.nextName()); + } + + public void testStrictUnquotedStrings() throws IOException { + JsonReader reader = new JsonReader(new StringReader("[a]")); + reader.beginArray(); + try { + reader.nextString(); + fail(); + } catch (IOException expected) { + } + } + + public void testLenientUnquotedStrings() throws IOException { + JsonReader reader = new JsonReader(new StringReader("[a]")); + reader.setLenient(true); + reader.beginArray(); + assertEquals("a", reader.nextString()); + } + + public void testStrictSingleQuotedStrings() throws IOException { + JsonReader reader = new JsonReader(new StringReader("['a']")); + reader.beginArray(); + try { + reader.nextString(); + fail(); + } catch (IOException expected) { + } + } + + public void testLenientSingleQuotedStrings() throws IOException { + JsonReader reader = new JsonReader(new StringReader("['a']")); + reader.setLenient(true); + reader.beginArray(); + assertEquals("a", reader.nextString()); + } + + public void testStrictSemicolonDelimitedArray() throws IOException { + JsonReader reader = new JsonReader(new StringReader("[true;true]")); + reader.beginArray(); + try { + reader.nextBoolean(); + reader.nextBoolean(); + fail(); + } catch (IOException expected) { + } + } + + public void testLenientSemicolonDelimitedArray() throws IOException { + JsonReader reader = new JsonReader(new StringReader("[true;true]")); + reader.setLenient(true); + reader.beginArray(); + assertEquals(true, reader.nextBoolean()); + assertEquals(true, reader.nextBoolean()); + } + + public void testStrictSemicolonDelimitedNameValuePair() throws IOException { + JsonReader reader = new JsonReader(new StringReader("{\"a\":true;\"b\":true}")); + reader.beginObject(); + assertEquals("a", reader.nextName()); + try { + reader.nextBoolean(); + reader.nextName(); + fail(); + } catch (IOException expected) { + } + } + + public void testLenientSemicolonDelimitedNameValuePair() throws IOException { + JsonReader reader = new JsonReader(new StringReader("{\"a\":true;\"b\":true}")); + reader.setLenient(true); + reader.beginObject(); + assertEquals("a", reader.nextName()); + assertEquals(true, reader.nextBoolean()); + assertEquals("b", reader.nextName()); + } + + public void testStrictUnnecessaryArraySeparators() throws IOException { + JsonReader reader = new JsonReader(new StringReader("[true,,true]")); + reader.beginArray(); + assertEquals(true, reader.nextBoolean()); + try { + reader.nextNull(); + fail(); + } catch (IOException expected) { + } + + reader = new JsonReader(new StringReader("[,true]")); + reader.beginArray(); + try { + reader.nextNull(); + fail(); + } catch (IOException expected) { + } + + reader = new JsonReader(new StringReader("[true,]")); + reader.beginArray(); + assertEquals(true, reader.nextBoolean()); + try { + reader.nextNull(); + fail(); + } catch (IOException expected) { + } + + reader = new JsonReader(new StringReader("[,]")); + reader.beginArray(); + try { + reader.nextNull(); + fail(); + } catch (IOException expected) { + } + } + + public void testLenientUnnecessaryArraySeparators() throws IOException { + JsonReader reader = new JsonReader(new StringReader("[true,,true]")); + reader.setLenient(true); + reader.beginArray(); + assertEquals(true, reader.nextBoolean()); + reader.nextNull(); + assertEquals(true, reader.nextBoolean()); + reader.endArray(); + + reader = new JsonReader(new StringReader("[,true]")); + reader.setLenient(true); + reader.beginArray(); + reader.nextNull(); + assertEquals(true, reader.nextBoolean()); + reader.endArray(); + + reader = new JsonReader(new StringReader("[true,]")); + reader.setLenient(true); + reader.beginArray(); + assertEquals(true, reader.nextBoolean()); + reader.nextNull(); + reader.endArray(); + + reader = new JsonReader(new StringReader("[,]")); + reader.setLenient(true); + reader.beginArray(); + reader.nextNull(); + reader.nextNull(); + reader.endArray(); + } } diff --git a/core/tests/coretests/src/android/util/JsonWriterTest.java b/core/tests/coretests/src/android/util/JsonWriterTest.java index 0bf7e042a7ac9..fa840239d7b80 100644 --- a/core/tests/coretests/src/android/util/JsonWriterTest.java +++ b/core/tests/coretests/src/android/util/JsonWriterTest.java @@ -346,7 +346,7 @@ public final class JsonWriterTest extends TestCase { public void testPrettyPrintObject() throws IOException { StringWriter stringWriter = new StringWriter(); JsonWriter jsonWriter = new JsonWriter(stringWriter); - jsonWriter.setIndentSpaces(3); + jsonWriter.setIndent(" "); jsonWriter.beginObject(); jsonWriter.name("a").value(true); @@ -383,7 +383,7 @@ public final class JsonWriterTest extends TestCase { public void testPrettyPrintArray() throws IOException { StringWriter stringWriter = new StringWriter(); JsonWriter jsonWriter = new JsonWriter(stringWriter); - jsonWriter.setIndentSpaces(3); + jsonWriter.setIndent(" "); jsonWriter.beginArray(); jsonWriter.value(true);