From e3903ecda50217685c1291e465d178b5a5ed76ff Mon Sep 17 00:00:00 2001 From: Ethan McCue Date: Wed, 1 Mar 2023 02:35:34 -0500 Subject: [PATCH 1/3] Fix nullable decoder and add more tests --- src/main/java/dev/mccue/json/JsonArray.java | 3 - src/main/java/dev/mccue/json/JsonDecoder.java | 13 +- .../serialization/JsonSerializationProxy.java | 2 +- .../java/dev/mccue/json/JsonDecoderTest.java | 128 +++++++++++++++++- src/test/java/dev/mccue/json/JsonTest.java | 15 ++ .../dev/mccue/json/SerializationTest.java | 115 +++++++++++++++- 6 files changed, 268 insertions(+), 8 deletions(-) diff --git a/src/main/java/dev/mccue/json/JsonArray.java b/src/main/java/dev/mccue/json/JsonArray.java index 759f2eb..d4671e9 100644 --- a/src/main/java/dev/mccue/json/JsonArray.java +++ b/src/main/java/dev/mccue/json/JsonArray.java @@ -8,7 +8,6 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; -import java.util.Objects; /** * Represents an array in the json data model. @@ -19,8 +18,6 @@ static JsonArray of(Json... values) { } static JsonArray of(List value) { - Objects.requireNonNull(value, "Json.Array value must be nonnull"); - value.forEach(json -> Objects.requireNonNull(json, "Each value in a Json.Array must be nonnull")); return new ArrayImpl(List.copyOf(value)); } diff --git a/src/main/java/dev/mccue/json/JsonDecoder.java b/src/main/java/dev/mccue/json/JsonDecoder.java index 4b1d712..9dc5324 100644 --- a/src/main/java/dev/mccue/json/JsonDecoder.java +++ b/src/main/java/dev/mccue/json/JsonDecoder.java @@ -172,6 +172,17 @@ static T null_(Json json) throws JsonDecodeException { } } + static T null_(Json json, T value) throws JsonDecodeException { + if (!(json instanceof JsonNull)) { + throw JsonDecodeException.of( + "expected null", + json + ); + } else { + return value; + } + } + static JsonDecoder> array(JsonDecoder itemDecoder) throws JsonDecodeException { return json -> array(json, itemDecoder); } @@ -420,7 +431,7 @@ static JsonDecoder nullable(JsonDecoder decoder, T defaultVa return json -> JsonDecoder.oneOf( json, decoder, - __ -> defaultValue + JsonDecoder.of(JsonDecoder::null_).map(__ -> defaultValue) ); } diff --git a/src/main/java/dev/mccue/json/serialization/JsonSerializationProxy.java b/src/main/java/dev/mccue/json/serialization/JsonSerializationProxy.java index fdc4955..6cc3c98 100644 --- a/src/main/java/dev/mccue/json/serialization/JsonSerializationProxy.java +++ b/src/main/java/dev/mccue/json/serialization/JsonSerializationProxy.java @@ -20,7 +20,7 @@ * freely. *

* - * @param json + * @param json A string representation of the JSON being serialized. */ public record JsonSerializationProxy(String json) implements Serializable { @Serial diff --git a/src/test/java/dev/mccue/json/JsonDecoderTest.java b/src/test/java/dev/mccue/json/JsonDecoderTest.java index 678cec8..cdec6e3 100644 --- a/src/test/java/dev/mccue/json/JsonDecoderTest.java +++ b/src/test/java/dev/mccue/json/JsonDecoderTest.java @@ -2,12 +2,14 @@ import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.math.BigInteger; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.*; public class JsonDecoderTest { @Test @@ -84,4 +86,126 @@ static Person fromJson(Json json) { ) ); } + + @Test + public void nullableDecoderTest() { + assertEquals( + Optional.of("abc"), + JsonDecoder.nullable(JsonDecoder::string) + .decode(JsonString.of("abc")) + ); + assertEquals( + Optional.empty(), + JsonDecoder.nullable(JsonDecoder::string) + .decode(JsonNull.instance()) + ); + assertThrows( + JsonDecodeException.class, + () -> JsonDecoder.nullable(JsonDecoder::string) + .decode(JsonArray.of()) + ); + + assertEquals( + "abc", + JsonDecoder.nullable(JsonDecoder::string, null) + .decode(JsonString.of("abc")) + ); + assertNull(JsonDecoder.nullable(JsonDecoder::string, null) + .decode(JsonNull.instance())); + assertEquals( + "def", + JsonDecoder.nullable(JsonDecoder::string, "def") + .decode(JsonNull.instance()) + ); + assertThrows( + JsonDecodeException.class, + () -> JsonDecoder.nullable(JsonDecoder::string, null) + .decode(JsonArray.of()) + ); + } + + @Test + public void booleanDecoderTest() { + assertTrue(JsonDecoder.boolean_(JsonTrue.instance())); + assertFalse(JsonDecoder.boolean_(JsonFalse.instance())); + assertThrows( + JsonDecodeException.class, + () -> JsonDecoder.boolean_(JsonString.of("abc")) + ); + } + + @Test + public void stringDecoderTest() { + assertEquals("abc", JsonDecoder.string(JsonString.of("abc"))); + assertThrows( + JsonDecodeException.class, + () -> JsonDecoder.string(JsonNull.instance()) + ); + } + + @Test + public void intDecoderTest() { + assertEquals(1, JsonDecoder.int_(Json.of(1))); + assertEquals(2, JsonDecoder.int_(Json.of(2L))); + assertEquals(3, JsonDecoder.int_(Json.of(new BigInteger("3")))); + assertEquals(4, JsonDecoder.int_(Json.of(new BigDecimal("4")))); + assertThrows( + JsonDecodeException.class, + () -> JsonDecoder.int_(Json.of(Long.MAX_VALUE)) + ); + assertThrows( + JsonDecodeException.class, + () -> JsonDecoder.int_(Json.of("abc")) + ); + } + + @Test + public void longDecoderTest() { + assertEquals(1L, JsonDecoder.long_(Json.of(1))); + assertEquals(2L, JsonDecoder.long_(Json.of(2L))); + assertEquals(3L, JsonDecoder.long_(Json.of(new BigInteger("3")))); + assertEquals(4L, JsonDecoder.long_(Json.of(new BigDecimal("4")))); + assertThrows( + JsonDecodeException.class, + () -> JsonDecoder.long_(Json.of(new BigDecimal("43242523525235235255"))) + ); + assertThrows( + JsonDecodeException.class, + () -> JsonDecoder.long_(Json.of("abc")) + ); + } + + @Test + public void floatDecoderTest() { + assertEquals(1f, JsonDecoder.float_(Json.of(1))); + assertEquals(2f, JsonDecoder.float_(Json.of(2L))); + assertEquals(3f, JsonDecoder.float_(Json.of(new BigInteger("3")))); + assertEquals(4f, JsonDecoder.float_(Json.of(new BigDecimal("4")))); + assertThrows( + JsonDecodeException.class, + () -> JsonDecoder.long_(Json.of("abc")) + ); + } + + @Test + public void doubleDecoderTest() { + assertEquals(1L, JsonDecoder.double_(Json.of(1))); + assertEquals(2L, JsonDecoder.double_(Json.of(2L))); + assertEquals(3L, JsonDecoder.double_(Json.of(new BigInteger("3")))); + assertEquals(4L, JsonDecoder.double_(Json.of(new BigDecimal("4")))); + assertThrows( + JsonDecodeException.class, + () -> JsonDecoder.double_(Json.of("abc")) + ); + } + + @Test + public void nullDecoderTest() { + assertNull(JsonDecoder.null_(JsonNull.instance())); + assertEquals(5, JsonDecoder.null_(JsonNull.instance(), 5)); + assertThrows( + JsonDecodeException.class, + () -> JsonDecoder.null_(Json.of("abc")) + ); + } } diff --git a/src/test/java/dev/mccue/json/JsonTest.java b/src/test/java/dev/mccue/json/JsonTest.java index 5afd753..7165e16 100644 --- a/src/test/java/dev/mccue/json/JsonTest.java +++ b/src/test/java/dev/mccue/json/JsonTest.java @@ -78,4 +78,19 @@ public void testOfNumbers() { public void testOfString() { assertEquals(Json.of("abc"), JsonString.of("abc")); } + + @Test + public void testTrueRepr() { + assertEquals("true", JsonTrue.instance().toString()); + } + + @Test + public void testFalseRepr() { + assertEquals("false", JsonFalse.instance().toString()); + } + + @Test + public void testNullRepr() { + assertEquals("null", JsonNull.instance().toString()); + } } diff --git a/src/test/java/dev/mccue/json/SerializationTest.java b/src/test/java/dev/mccue/json/SerializationTest.java index c0fcb16..4c8ab9e 100644 --- a/src/test/java/dev/mccue/json/SerializationTest.java +++ b/src/test/java/dev/mccue/json/SerializationTest.java @@ -1,11 +1,14 @@ package dev.mccue.json; +import dev.mccue.json.serialization.JsonSerializationProxy; import org.junit.jupiter.api.Test; import java.io.*; +import java.util.Arrays; +import java.util.Map; import java.util.Objects; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.*; public class SerializationTest { @Test @@ -32,4 +35,114 @@ public void testSerialization() throws IOException, ClassNotFoundException { assertEquals(json, ois.readObject()); } } + + @Test + public void testNonsenseForm() throws IOException, ClassNotFoundException { + byte[] data; + try (var baos = new ByteArrayOutputStream(); + var oos = new ObjectOutputStream(baos)) { + oos.writeObject(new JsonSerializationProxy("")); + oos.writeObject(new JsonSerializationProxy("{\"a\":")); + data = baos.toByteArray(); + } + + try (var bais = new ByteArrayInputStream(data); + var ois = new ObjectInputStream(bais)) { + assertThrows(JsonReadException.class, ois::readObject); + assertThrows(JsonReadException.class, ois::readObject); + } + } + + @Test + public void testMalicousStream() throws IOException, ClassNotFoundException { + byte[] jsonNull = { + -84, -19, 0, 5, 115, 114, 0, 23, 100, 101, 118, 46, 109, 99, 99, 117, 101, 46, 106, 115, 111, 110, 46, 74, 115, 111, 110, 78, 117, 108, 108, 0, 0, 0, 0, 0, 0, 0, 1, 2, 0, 0, 120, 112 + }; + + byte[] jsonTrue = { + -84, -19, 0, 5, 115, 114, 0, 23, 100, 101, 118, 46, 109, 99, 99, 117, 101, 46, 106, 115, 111, 110, 46, 74, 115, 111, 110, 84, 114, 117, 101, 0, 0, 0, 0, 0, 0, 0, 1, 2, 0, 0, 120, 112 + }; + + byte[] jsonFalse = { + -84, -19, 0, 5, 115, 114, 0, 24, 100, 101, 118, 46, 109, 99, 99, 117, 101, 46, 106, 115, 111, 110, 46, 74, 115, 111, 110, 70, 97, 108, 115, 101, 0, 0, 0, 0, 0, 0, 0, 1, 2, 0, 0, 120, 112 + }; + + /*byte[] data; + try (var baos = new ByteArrayOutputStream(); + var oos = new ObjectOutputStream(baos)) { + oos.writeObject(JsonFalse.instance()); + data = baos.toByteArray(); + } + + System.out.println(Arrays.toString(data));*/ + + + try (var ois = new ObjectInputStream(new ByteArrayInputStream(jsonNull))) { + assertThrows(IllegalStateException.class, ois::readObject); + } + + try (var ois = new ObjectInputStream(new ByteArrayInputStream(jsonTrue))) { + assertThrows(IllegalStateException.class, ois::readObject); + } + + try (var ois = new ObjectInputStream(new ByteArrayInputStream(jsonFalse))) { + assertThrows(IllegalStateException.class, ois::readObject); + } + } + + private Object roundTrip(Object o) throws IOException, ClassNotFoundException { + byte[] data; + try (var baos = new ByteArrayOutputStream(); + var oos = new ObjectOutputStream(baos)) { + oos.writeObject(o); + data = baos.toByteArray(); + } + + try (var bais = new ByteArrayInputStream(data); + var ois = new ObjectInputStream(bais)) { + return ois.readObject(); + } + } + + @Test + public void roundTripTrue() throws IOException, ClassNotFoundException { + assertEquals(JsonTrue.instance(), roundTrip(JsonTrue.instance())); + assertSame(JsonTrue.instance(), roundTrip(JsonTrue.instance())); + } + + @Test + public void roundTripFalse() throws IOException, ClassNotFoundException { + assertEquals(JsonFalse.instance(), roundTrip(JsonFalse.instance())); + assertSame(JsonFalse.instance(), roundTrip(JsonFalse.instance())); + } + + @Test + public void roundTripNull() throws IOException, ClassNotFoundException { + assertEquals(JsonNull.instance(), roundTrip(JsonNull.instance())); + assertSame(JsonNull.instance(), roundTrip(JsonNull.instance())); + } + + @Test + public void roundTripString() throws IOException, ClassNotFoundException { + assertEquals(JsonString.of("abc"), roundTrip(JsonString.of("abc"))); + } + + @Test + public void roundTripArray() throws IOException, ClassNotFoundException { + var o = JsonArray.of( + JsonString.of("abc"), + JsonNull.instance() + ); + assertEquals(o, roundTrip(o)); + } + + @Test + public void roundTripObject() throws IOException, ClassNotFoundException { + var o = JsonObject.of(Map.of( + "abc", JsonString.of("def"), + "ghi", JsonNull.instance(), + "kjl", JsonArray.of() + )); + assertEquals(o, roundTrip(o)); + } } From 63a7a8e04f2416556622a5fb6c315df44180458d Mon Sep 17 00:00:00 2001 From: Ethan McCue Date: Wed, 1 Mar 2023 02:37:45 -0500 Subject: [PATCH 2/3] Add tutorial and bump version --- README.md | 777 +++++++++++++++++++++++++++++++++++++++++++++++++++++- pom.xml | 2 +- 2 files changed, 776 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6dabaf8..46b0db3 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Requires Java 17+. dev.mccue json - 0.2.1 + 0.2.2 ``` @@ -24,7 +24,7 @@ Requires Java 17+. ``` dependencies { - implementation("dev.mccue:json:0.2.1") + implementation("dev.mccue:json:0.2.2") } ``` @@ -42,6 +42,779 @@ The non-goals of this library are 2. Support every extension to the JSON spec. 3. Handle documents which cannot fit into memory. +## Tutorial + +
+ Show + +### The Data Model + +JSON is a data format. It looks like the following sample. + +```json +{ + "name": "kermit", + "wife": null, + "girlfriend": "Ms. Piggy", + "age": 22, + "children": [ + { + "species": "frog", + "gender": "male" + }, + { + "species": "pig", + "gender": "female" + } + ], + "commitmentIssues": true +} +``` + +In JSON you represent data using a combination of objects (maps from strings to JSON), +arrays (ordered sequences of JSON), strings, numbers, true, false, and null. + +Therefore, one "natural" way to think about the data stored in a JSON document +is as the union of those possibilities. + +``` +JSON is one of +- a map of string to JSON +- a list of JSON +- a string +- a number +- true +- false +- null +``` + +The way to represent this in Java is using a sealed interface, which +provides an explicit list of types which are allowed to implement it. + +```java +public sealed interface Json + permits + JsonObject, + JsonArray, + JsonString, + JsonNumber, + JsonBoolean, + JsonNull { +} +``` + +This means that if you have a field or variable which has the type `Json`, you know +that it is either a `JsonObject`, `JsonArray`, `JsonString`, `JsonNumber`, `JsonBoolean`, +or `JsonNull`. + +That is the first thing provided by my library. There is a `Json` type +and subtypes representing those different cases. + +```java +import dev.mccue.json.*; + +public class Main { + static Json greeting() { + return JsonString.of("hello"); + } + + public static void main(String[] args) { + Json json = greeting(); + switch (json) { + case JsonObject object -> + System.out.println("An object"); + case JsonArray array -> + System.out.println("An array"); + case JsonString str -> + System.out.println("A string"); + case JsonNumber number -> + System.out.println("A number"); + case JsonBoolean bool -> + System.out.println("A boolean"); + case JsonNull __ -> + System.out.println("A json null"); + } + } +} +``` + +You can create instances +of these subtypes using factory methods on the types themselves. + +```java +import dev.mccue.json.*; + +import java.util.List; +import java.util.Map; + +public class Main { + public static void main(String[] args) { + JsonObject kermit = JsonObject.of(Map.of( + "name", JsonString.of("kermit"), + "age", JsonNumber.of(22), + "commitmentIssues", JsonBoolean.of(true), + "wife", JsonNull.instance(), + "children", JsonArray.of(List.of( + JsonString.of("Tiny Tim") + )) + )); + + System.out.println(kermit); + } +} +``` + +Or by using factory methods on `Json`, which aren't guaranteed to give you +any specific subtype but in exchange will handle converting any stray `null`s to `JsonNull`. + +```java +import dev.mccue.json.*; + +import java.util.List; +import java.util.Map; + +public class Main { + public static void main(String[] args) { + Json kermit = Json.of(Map.of( + "name", Json.of("kermit"), + "age", Json.of(22), + "commitmentIssues", Json.of(true), + "wife", Json.ofNull(), + "children", Json.of(List.of( + JsonString.of("Tiny Tim") + )) + )); + + System.out.println(kermit); + } +} +``` + +For `JsonObject` and `JsonArray`, there also use builders available which +can make it so that you don't need to write `Json.of` on every value. + +```java +import dev.mccue.json.Json; + +public class Main { + public static void main(String[] args) { + Json kermit = Json.objectBuilder() + .put("name", "kermit") + .put("age", 22) + .putTrue("commitmentIssues") + .putNull("wife") + .put("children", Json.arrayBuilder() + .add("Tiny Tim")) + .build(); + + System.out.println(kermit); + } +} +``` + +### Writing + +Once you have some `Json` you can write it out to a `String` using `Json.writeString` + +```java +import dev.mccue.json.Json; + +public class Main { + public static void main(String[] args) { + Json songJson = Json.objectBuilder() + .put("title", "Rainbow Connection") + .put("year", 1979) + .build(); + + String song = Json.writeString(songJson); + System.out.println(song); + } +} +``` + +```json +{"title":"Rainbow Connection","year":1979} +``` + +If output is meant to be consumed by humans then whitespace can be added +using a customized instance of `JsonWriteOptions`. + +```java +import dev.mccue.json.Json; +import dev.mccue.json.JsonWriteOptions; + +public class Main { + public static void main(String[] args) { + Json songJson = Json.objectBuilder() + .put("title", "Rainbow Connection") + .put("year", 1979) + .build(); + + String song = Json.writeString( + songJson, + new JsonWriteOptions() + .withIndentation(4) + ); + + System.out.println(song); + } +} +``` + +```json +{ + "title": "Rainbow Connection", + "year": 1979 +} +``` + +If you want to write JSON to something other than a `String`, you need to +obtain a `Writer` and use `Json.write`. + +```java +import dev.mccue.json.Json; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +public class Main { + public static void main(String[] args) throws IOException { + Json songJson = Json.objectBuilder() + .put("title", "Rainbow Connection") + .put("year", 1979) + .build(); + + + try (var fileWriter = Files.newBufferedWriter( + Path.of("song.json")) + ) { + Json.write(songJson, fileWriter); + } + } +} +``` + +### Encoding + +To turn a class you have defined into JSON, you just need to make a method +which creates an instance of `Json` from the data stored in your class. + +```java +import dev.mccue.json.Json; + +record Muppet(String name) { + Json toJson() { + return Json.objectBuilder() + .put("name", name) + .build(); + } +} + +public class Main { + public static void main(String[] args) { + var beaker = new Muppet("beaker"); + Json beakerJson = beaker.toJson(); + + System.out.println(Json.writeString(beakerJson)); + } +} +``` + +This process is "encoding." You "encode" your data into JSON and then "write" +that JSON to some output. + +For classes that you did not define, the logic for the conversion just needs to live somewhere. +Dealer's choice where, but static methods are generally a good call. + +```java +import dev.mccue.json.Json; + +import java.time.Month; +import java.time.MonthDay; +import java.time.format.DateTimeFormatter; + +final class TimeEncoders { + private TimeEncoders() {} + + static Json monthDayToJson(MonthDay monthDay) { + return Json.of( + DateTimeFormatter.ofPattern("MM-dd") + .format(monthDay) + ); + } +} + +record Muppet(String name, MonthDay birthday) { + Json toJson() { + return Json.objectBuilder() + .put("name", name) + .put( + "birthday", + TimeEncoders.monthDayToJson(birthday) + ) + .build(); + } +} + +public class Main { + public static void main(String[] args) { + var elmo = new Muppet( + "Elmo", + MonthDay.of(Month.FEBRUARY, 3) + ); + Json elmoJson = elmo.toJson(); + + System.out.println(Json.writeString(elmoJson)); + } +} +``` + +```json +{"name":"Elmo","birthday":"02-03"} +``` + +If a class you define has a JSON representation that could be considered "canonical", the interface `JsonEncodable` +can be implemented. This will let you pass an instance of the class directly to `Json.writeString` or `Json.write`. + +```java +import dev.mccue.json.Json; +import dev.mccue.json.JsonEncodable; + +record Muppet(String name, boolean great) + implements JsonEncodable { + @Override + public Json toJson() { + return Json.objectBuilder() + .put("name", name) + .put("great", great) + .build(); + } +} + +public class Main { + public static void main(String[] args) { + var gonzo = new Muppet("Gonzo", true); + System.out.println(Json.writeString(gonzo)); + } +} +``` + +### Reading + +The inverse of writing JSON is reading it. + +If you have some JSON stored in a `String` you can +read it into `Json` using `Json.readString`. + +```java +import dev.mccue.json.Json; + +public class Main { + public static void main(String[] args) { + Json movie = Json.readString(""" + { + "title": "Treasure Island", + "cast": [ + { + "name": "Kermit", + "role": "The Captain", + "muppet": true + }, + { + "name": "Tim Curry", + "role": "Long John Silver", + "muppet": false + } + ] + + } + """); + + System.out.println(movie); + } +} +``` + +If that JSON is coming from another source, you need to obtain a `Reader` and use `Json.read`. + +```java +import dev.mccue.json.Json; + +import java.io.IOException; +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; + +public class Main { + public static void main(String[] args) throws IOException { + // If you were following along, we created this earlier! + Json song; + try (Reader fileReader = Files.newBufferedReader( + Path.of("song.json")) + ) { + song = Json.read(fileReader); + } + + System.out.println(song); + } +} +``` + +If the JSON you provide is malformed in some way, a `JsonReadException` will be thrown. + +```java +import dev.mccue.json.Json; + +public class Main { + public static void main(String[] args) { + // Should be in quotes + Json.readString("fozzie"); + } +} +``` + +```java +Exception in thread "main" dev.mccue.json.JsonReadException: JSON error (unexpected character): f + at dev.mccue.json.JsonReadException.unexpectedCharacter(JsonReadException.java:33) + at dev.mccue.json.internal.JsonReaderMethods.readStream(JsonReaderMethods.java:525) + at dev.mccue.json.internal.JsonReaderMethods.read(JsonReaderMethods.java:533) + at dev.mccue.json.internal.JsonReaderMethods.readFullyConsume(JsonReaderMethods.java:543) + at dev.mccue.json.Json.readString(Json.java:369) + at dev.mccue.json.Json.readString(Json.java:364) + at dev.mccue.example.Main.main(Main.java:9) +``` + +### Decoding + +Up to this point, everything has been more or less the same as it is for other "tree-based" +JSON libraries like [org.json](https://github.com/stleary/JSON-java) or [json-simple](https://github.com/fangyidong/json-simple). + +This is where that will start to change. + +To take some `Json` and turn it into a user defined class, a basic approach would be to use `instanceof` checks to see if +the `Json` is a particular subtype and navigate from there. + +```java +import dev.mccue.json.*; + +record Muppet(String name, boolean canSpeak) { + static Muppet fromJson(Json json) { + if (json instanceof JsonObject object && + object.get("name") instanceof JsonString name && + object.get("canSpeak") instanceof JsonBoolean canSpeak) { + return new Muppet(name.toString(), canSpeak.value()); + } + else { + throw new RuntimeException("Invalid Muppet"); + } + } +} + +public class Main { + public static void main(String[] args) { + var json = Json.readString(""" + { + "name": "animal", + "canSpeak": false + } + """); + + var animal = Muppet.fromJson(json); + + System.out.println(animal); + } +} +``` + +This process is "decoding." You "read" your data into JSON and then "decode" +it to some type you define. + +The problem with the `instanceof` approach is that you will end up with bad error messages on unexpected data. +In this case the error message would just be `"Invalid Muppet"`. The code to get better errors is tedious to write +and I haven't seen many folks in the wild do it. + +To get good errors, you should use the static methods defined in `JsonDecoder`. + +```java +package dev.mccue.example; + +import dev.mccue.json.*; + +record Muppet(String name, boolean canSpeak) { + static Muppet fromJson(Json json) { + return new Muppet( + JsonDecoder.field( + json, + "name", + JsonDecoder::string + ), + JsonDecoder.field( + json, + "canSpeak", + JsonDecoder::boolean_ + ) + ); + } +} + +public class Main { + public static void main(String[] args) { + var json = Json.readString(""" + { + "name": "animal", + "canSpeak": false + } + """); + + var animal = Muppet.fromJson(json); + + System.out.println(animal); + } +} +``` + +These handle the fiddly process of checking whether the JSON matches the structure you +expect and throwing an appropriate error. + +You should read this declaration as "at the field `name` I expect a string." + +```java +JsonDecoder.field(json, "name", JsonDecoder::string) +``` + +If the JSON is not an object, or doesn't have a value for `name`, or that value +is not a string, you will get a `JsonDecodeException`. + +```java +public class Main { + public static void main(String[] args) { + var json = Json.readString(""" + { + "canSpeak": false + } + """); + + var animal = JsonDecoder.field( + json, + "name", + JsonDecoder::string + ); + + System.out.println(animal); + } +} +``` +Which will have a message indicating exactly what went wrong and where. + +```java +Problem with the value at json.name: + + { + "canSpeak": false + } + +no value for field +``` + +The last argument to `JsonDecoder.field` is the `JsonDecoder` you want to use to interpret the value at that field. +In this case a method reference to `JsonDecoder.string`, which is a method that asserts JSON is a string +and throws if it isn't. + +For the methods which take more than one argument, there are overloads +which can be used to get an instance of `JsonDecoder`. + +```java +// This will actually decode the json into a list of strings +List items = JsonDecoder.array(json, JsonDecoder::string); + +// This will just return a decoder +Decoder> decoder = + JsonDecoder.array(JsonDecoder::string); +``` + +This, in conjunction with `JsonDecoder.field` is how you are intended to explore nested paths. + +```java +public class Main { + public static void main(String[] args) { + var json = Json.readString(""" + { + "villains": ["constantine", "doc hopper"] + } + """); + + List villains = JsonDecoder.field( + json, + "villains", + JsonDecoder.array(JsonDecoder::string) + ); + + System.out.println(villains); + } +} +``` + +To decode JSON into your custom classes, you should add either a constructor or +a static factory method which takes in `Json` and use these decoders to make your objects. + +```java +import dev.mccue.json.*; + +import java.util.List; + +record Actor(String name, String role, boolean muppet) { + static Actor fromJson(Json json) { + return new Actor( + JsonDecoder.field(json, "name", JsonDecoder::string), + JsonDecoder.field(json, "role", JsonDecoder::string), + JsonDecoder.optionalField( + json, + "muppet", + JsonDecoder::boolean_, + true + ) + ); + } +} + + +record Movie(String title, List cast) { + static Movie fromJson(Json json) { + return new Movie( + JsonDecoder.field(json, "title", JsonDecoder::string), + JsonDecoder.field( + json, + "cast", + JsonDecoder.array(Actor::fromJson) + ) + ); + } +} + +public class Main { + public static void main(String[] args) { + var json = Json.readString(""" + { + "title": "Treasure Island", + "cast": [ + { + "name": "Kermit", + "role": "The Captain" + }, + { + "name": "Tim Curry", + "role": "Long John Silver", + "muppet": false + } + ] + } + """); + + var movie = Movie.fromJson(json); + + System.out.println(movie); + } +} +``` + +### Full Round-Trip + +With all of that out of the way, here is how you might define a model, +write it to json, and read it back in. + +```java +import dev.mccue.json.*; + +import java.util.List; + +record Actor(String name, String role, boolean muppet) + implements JsonEncodable { + static Actor fromJson(Json json) { + return new Actor( + JsonDecoder.field(json, "name", JsonDecoder::string), + JsonDecoder.field(json, "role", JsonDecoder::string), + JsonDecoder.optionalField( + json, + "muppet", + JsonDecoder::boolean_, + true) + ); + } + + @Override + public Json toJson() { + return Json.objectBuilder() + .put("name", name) + .put("role", role) + .put("muppet", muppet) + .build(); + } +} + + +record Movie(String title, List cast) + implements JsonEncodable { + static Movie fromJson(Json json) { + return new Movie( + JsonDecoder.field(json, "title", JsonDecoder::string), + JsonDecoder.field( + json, + "cast", + JsonDecoder.array(Actor::fromJson) + ) + ); + } + + @Override + public Json toJson() { + return Json.objectBuilder() + .put("title", title) + .put("cast", cast) + .build(); + } +} + +public class Main { + public static void main(String[] args) { + var json = Json.readString(""" + { + "title": "Treasure Island", + "cast": [ + { + "name": "Kermit", + "role": "The Captain", + "muppet": true + }, + { + "name": "Tim Curry", + "role": "Long John Silver", + "muppet": false + } + ] + } + """); + + var movie = Movie.fromJson(json); + + var roundTrippedJson = Json.readString( + Json.writeString(movie.toJson()) + ); + var roundTrippedMovie = Movie.fromJson(roundTrippedJson); + + System.out.println( + json.equals(roundTrippedJson) + ); + + System.out.println( + movie.equals(roundTrippedMovie) + ); + } +} +``` +
+ + ## Examples ### Create Json from a String diff --git a/pom.xml b/pom.xml index 78c3d69..a7c530c 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ dev.mccue json - 0.2.1 + 0.2.2 jar From b408517d0f2ad78b06e5137d89506ff1946aeaf9 Mon Sep 17 00:00:00 2001 From: Ethan McCue Date: Wed, 1 Mar 2023 02:38:53 -0500 Subject: [PATCH 3/3] Make numbers example use JsonArray --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 46b0db3..e32be2c 100644 --- a/README.md +++ b/README.md @@ -843,6 +843,7 @@ public class Main { ```java import dev.mccue.json.Json; +import dev.mccue.json.JsonArray; import java.math.BigDecimal; import java.math.BigInteger; @@ -850,7 +851,7 @@ import java.util.List; public class Main { public static void main(String[] args) { - List numbers = List.of( + JsonArray numbers = JsonArray.of( Json.of(1), Json.of(2L), Json.of(3.5),