diff --git a/moshi/src/main/java/com/squareup/moshi/JsonAdapter.java b/moshi/src/main/java/com/squareup/moshi/JsonAdapter.java index faddb45..8e211de 100644 --- a/moshi/src/main/java/com/squareup/moshi/JsonAdapter.java +++ b/moshi/src/main/java/com/squareup/moshi/JsonAdapter.java @@ -78,7 +78,7 @@ public abstract class JsonAdapter { }; } - /** Returns a JSON adapter equal to this JSON adapter, but is lenient when reading and writing. */ + /** Returns a JSON adapter equal to this, but is lenient when reading and writing. */ public final JsonAdapter lenient() { final JsonAdapter delegate = this; return new JsonAdapter() { @@ -103,6 +103,30 @@ public abstract class JsonAdapter { }; } + /** + * Returns a JSON adapter equal to this, but that throws a {@link JsonDataException} when + * {@linkplain JsonReader#setFailOnUnknown(boolean) unknown values} are encountered. This + * constraint applies to both the top-level message handled by this type adapter as well as to + * nested messages. + */ + public final JsonAdapter failOnUnknown() { + final JsonAdapter delegate = this; + return new JsonAdapter() { + @Override public T fromJson(JsonReader reader) throws IOException { + boolean skipForbidden = reader.failOnUnknown(); + reader.setFailOnUnknown(true); + try { + return delegate.fromJson(reader); + } finally { + reader.setFailOnUnknown(skipForbidden); + } + } + @Override public void toJson(JsonWriter writer, T value) throws IOException { + delegate.toJson(writer, value); + } + }; + } + public interface Factory { /** * Attempts to create an adapter for {@code type} annotated with {@code annotations}. This diff --git a/moshi/src/main/java/com/squareup/moshi/JsonReader.java b/moshi/src/main/java/com/squareup/moshi/JsonReader.java index cef68af..4222fc5 100644 --- a/moshi/src/main/java/com/squareup/moshi/JsonReader.java +++ b/moshi/src/main/java/com/squareup/moshi/JsonReader.java @@ -214,6 +214,9 @@ public final class JsonReader implements Closeable { /** True to accept non-spec compliant JSON */ private boolean lenient = false; + /** True to throw a {@link JsonDataException} on any attempt to call {@link #skipValue()}. */ + private boolean failOnUnknown = false; + /** The input JSON. */ private final BufferedSource source; private final Buffer buffer; @@ -263,7 +266,7 @@ public final class JsonReader implements Closeable { } /** - * Configure this parser to be be liberal in what it accepts. By default, + * Configure this parser to 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: @@ -302,6 +305,25 @@ public final class JsonReader implements Closeable { return lenient; } + /** + * Configure whether this parser throws a {@link JsonDataException} when {@link #skipValue} is + * called. By default this parser permits values to be skipped. + * + *

Forbid skipping to prevent unrecognized values from being silently ignored. This option is + * useful in development and debugging because it means a typo like "locatiom" will be detected + * early. It's potentially harmful in production because it complicates revising a JSON schema. + */ + public void setFailOnUnknown(boolean failOnUnknown) { + this.failOnUnknown = failOnUnknown; + } + + /** + * Returns true if this parser forbids skipping values. + */ + public boolean failOnUnknown() { + return failOnUnknown; + } + /** * Consumes the next token from the JSON stream and asserts that it is the beginning of a new * array. @@ -1082,8 +1104,14 @@ public final class JsonReader implements Closeable { * Skips the next value recursively. If it is an object or array, all nested elements are skipped. * This method is intended for use when the JSON token stream contains unrecognized or unhandled * values. + * + *

This throws a {@link JsonDataException} if this parser has been configured to {@linkplain + * #failOnUnknown fail on unknown} values. */ public void skipValue() throws IOException { + if (failOnUnknown) { + throw new JsonDataException("Cannot skip unexpected " + peek() + " at " + getPath()); + } int count = 0; do { int p = peeked; diff --git a/moshi/src/test/java/com/squareup/moshi/JsonReaderTest.java b/moshi/src/test/java/com/squareup/moshi/JsonReaderTest.java index af54873..298a3fe 100644 --- a/moshi/src/test/java/com/squareup/moshi/JsonReaderTest.java +++ b/moshi/src/test/java/com/squareup/moshi/JsonReaderTest.java @@ -19,7 +19,6 @@ import java.io.EOFException; import java.io.IOException; import java.util.Arrays; import okio.Buffer; -import org.assertj.core.data.Offset; import org.junit.Ignore; import org.junit.Test; @@ -191,6 +190,42 @@ public final class JsonReaderTest { assertThat(reader.peek()).isEqualTo(JsonReader.Token.END_DOCUMENT); } + @Test public void failOnUnknownFailsOnUnknownObjectValue() throws IOException { + JsonReader reader = newReader("{\"a\": 123}"); + reader.setFailOnUnknown(true); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + try { + reader.skipValue(); + fail(); + } catch (JsonDataException expected) { + assertThat(expected).hasMessage("Cannot skip unexpected NUMBER at $.a"); + } + // Confirm that the reader is left in a consistent state after the exception. + reader.setFailOnUnknown(false); + assertThat(reader.nextInt()).isEqualTo(123); + reader.endObject(); + assertThat(reader.peek()).isEqualTo(JsonReader.Token.END_DOCUMENT); + } + + @Test public void failOnUnknownFailsOnUnknownArrayElement() throws IOException { + JsonReader reader = newReader("[\"a\", 123]"); + reader.setFailOnUnknown(true); + reader.beginArray(); + assertThat(reader.nextString()).isEqualTo("a"); + try { + reader.skipValue(); + fail(); + } catch (JsonDataException expected) { + assertThat(expected).hasMessage("Cannot skip unexpected NUMBER at $[1]"); + } + // Confirm that the reader is left in a consistent state after the exception. + reader.setFailOnUnknown(false); + assertThat(reader.nextInt()).isEqualTo(123); + reader.endArray(); + assertThat(reader.peek()).isEqualTo(JsonReader.Token.END_DOCUMENT); + } + @Test public void helloWorld() throws IOException { String json = "{\n" + " \"hello\": true,\n" + diff --git a/moshi/src/test/java/com/squareup/moshi/MoshiTest.java b/moshi/src/test/java/com/squareup/moshi/MoshiTest.java index 50b7f00..ed4c653 100644 --- a/moshi/src/test/java/com/squareup/moshi/MoshiTest.java +++ b/moshi/src/test/java/com/squareup/moshi/MoshiTest.java @@ -688,6 +688,25 @@ public final class MoshiTest { assertThat(adapter.toJson(null)).isEqualTo("null"); } + @Test public void byDefaultUnknownFieldsAreIgnored() throws Exception { + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter adapter = moshi.adapter(Pizza.class); + Pizza pizza = adapter.fromJson("{\"diameter\":5,\"crust\":\"thick\",\"extraCheese\":true}"); + assertThat(pizza.diameter).isEqualTo(5); + assertThat(pizza.extraCheese).isEqualTo(true); + } + + @Test public void failOnUnknownThrowsOnUnknownFields() throws Exception { + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter adapter = moshi.adapter(Pizza.class).failOnUnknown(); + try { + adapter.fromJson("{\"diameter\":5,\"crust\":\"thick\",\"extraCheese\":true}"); + fail(); + } catch (JsonDataException expected) { + assertThat(expected).hasMessage("Cannot skip unexpected STRING at $.crust"); + } + } + static class Pizza { final int diameter; final boolean extraCheese;