Repository: jchambers/pushy
Pull request #775 (2020-05-02 03:08:17)

Drop GSON as a dependency
Folks have expressed a preference to be able to use the JSON library of their choice when serializing JSON payloads. As a step down that road, this pull request drops our dependency on GSON entirely. It does so by introducing our own internal JSON parser (built with [JavaCC](https://javacc.github.io/javacc/)) and our own internal JSON serializer.

Some of you might think this is an excessive step and a textbook example of "not invented here" thinking, and you're probably right. On a personal note, it was interesting for me to learn more about building parsers, and it was a welcome distraction during a difficult time. On the merits, though, this really _does_ make it easier to avoid religious wars over JSON libraries by way of not really taking a side.

In terms of performance, benchmarks show our own JSON serializer to be somewhere between GSON and Jackson in terms of performance for Pushy's use cases. I still need to write some benchmarks for the parser.

It's important to note that this _does_ represent a change in behavior for the payload builder, and particularly in how we handle non-primitive objects added as custom properties. I'll be following this up with pull requests to make it easier to choose a specific JSON library for payload serialization, and those will surely be breaking API changes, so I hope that'll help folks notice that payload serialization has changed and may need attention.

Another important note: depending on how you're building Pushy, you may need to run `mvn generate-sources` to actually generate the parser from the JavaCC file.

This is a big change, and feedback is very welcome.

jchambers: > I still need to write some benchmarks for the parser.

I wrote some benchmarks for the parser. The idea is to parse a bunch of randomly-generated error responses, because that's the only time clients actually parse JSON. Here's how things stacked up:

| Library | Throughput (operations/second) |
|---------|--------------------------------|
| GSON    |     1,179,635.567 ± 10,643.227 |
| Jackson |     2,382,039.407 ± 27,617.493 |
| Pushy   |       519,082.270 ± 64,789.183 |

So, yes, we're quite a bit slower than the competition when it comes to parsing. At the same time, I don't think that's actually a big deal in practical terms. Let's say Pushy is cranking through 50,000 notifications per second. Let's say 10% of those notifications will result in an error response from the server, which works out to 5,000 notifications per second.

GSON, which we were using before this change, could parse about a million error responses per second, which works out to about 1µs per error response. If we're half as fast, that means we're taking 2µs per error response. In other words, this change adds about 1µs per error response.

Putting it all together, that would mean that this change would add about 5,000µs (5ms) to the processing time for that batch of 50,000 notifications, which I think is pretty negligible. I am, of course, open to arguments to the contrary.
jchambers: I did some tuning and have substantially improved the performance of our own parser in 
fcbdbac:

| Library | Throughput (operations/second) |
|---------|--------------------------------|
| GSON    |    1,079,586.362 ± 10,528.352  |
| Jackson |    2,255,141.586 ± 85,957.339  |
| Pushy   |      852,380.653 ±  7,198.073  |

It's still a little slower than GSON, but the gap is now much, much smaller.

Patch:
diff --git a/pom.xml b/pom.xml
index 1e854e96..7f68b5ed 100644
--- a/pom.xml
+++ b/pom.xml
@@ -110,11 +110,6 @@
                 <artifactId>fast-uuid</artifactId>
                 <version>0.1</version>
             </dependency>
-            <dependency>
-                <groupId>com.google.code.gson</groupId>
-                <artifactId>gson</artifactId>
-                <version>2.8.0</version>
-            </dependency>
             <dependency>
                 <groupId>org.slf4j</groupId>
                 <artifactId>slf4j-api</artifactId>
diff --git a/pushy/pom.xml b/pushy/pom.xml
index 7ecb4a6b..f2708e69 100644
--- a/pushy/pom.xml
+++ b/pushy/pom.xml
@@ -52,10 +52,6 @@
             <groupId>com.eatthepath</groupId>
             <artifactId>fast-uuid</artifactId>
         </dependency>
-        <dependency>
-            <groupId>com.google.code.gson</groupId>
-            <artifactId>gson</artifactId>
-        </dependency>
         <dependency>
             <groupId>org.slf4j</groupId>
             <artifactId>slf4j-api</artifactId>
@@ -102,6 +98,26 @@
 
     <build>
         <plugins>
+            <plugin>
+                <groupId>com.helger.maven</groupId>
+                <artifactId>ph-javacc-maven-plugin</artifactId>
+                <version>4.1.3</version>
+                <executions>
+                    <execution>
+                        <id>generate-parser</id>
+                        <phase>generate-sources</phase>
+                        <goals>
+                            <goal>javacc</goal>
+                        </goals>
+                        <configuration>
+                            <jdkVersion>1.5</jdkVersion>
+                            <sourceDirectory>src/main/javacc</sourceDirectory>
+                            <outputDirectory>${project.build.directory}/generated-sources/javacc</outputDirectory>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+
             <plugin>
                 <groupId>org.apache.felix</groupId>
                 <artifactId>maven-bundle-plugin</artifactId>
@@ -127,7 +143,6 @@
                     <links>
                         <link>https://docs.oracle.com/javase/8/docs/api/</link>
                         <link>https://netty.io/4.1/api/</link>
-                        <link>https://www.javadoc.io/doc/com.google.code.gson/gson/2.8.0/</link>
                     </links>
                 </configuration>
             </plugin>
diff --git a/pushy/src/main/java/com/eatthepath/json/JsonDeserializer.java b/pushy/src/main/java/com/eatthepath/json/JsonDeserializer.java
new file mode 100644
index 00000000..d38407c5
--- /dev/null
+++ b/pushy/src/main/java/com/eatthepath/json/JsonDeserializer.java
@@ -0,0 +1,77 @@
+package com.eatthepath.json;
+
+import java.io.StringReader;
+import java.util.Map;
+
+/**
+ * <p>An extremely simple JSON object deserializer that interprets JSON objects as Java primitives. JSON types are
+ * mapped to Java types as follows:</p>
+ *
+ * <table>
+ *     <caption>Mapping of JSON types to Java types</caption>
+ *
+ *     <thead>
+ *         <tr>
+ *             <th>JSON type</th>
+ *             <th>Java type</th>
+ *         </tr>
+ *     </thead>
+ *
+ *     <tbody>
+ *         <tr>
+ *             <td>string</td>
+ *             <td>{@link String}</td>
+ *         </tr>
+ *
+ *         <tr>
+ *             <td>number</td>
+ *             <td>{@link Number} ({@link Long} or {@link Double})</td>
+ *         </tr>
+ *
+ *         <tr>
+ *             <td>object</td>
+ *             <td>{@link Map}&lt;{@link String}, {@link Object}&gt;</td>
+ *         </tr>
+ *
+ *         <tr>
+ *             <td>array</td>
+ *             <td>{@link java.util.List}&lt;{@link Object}&gt;</td>
+ *         </tr>
+ *
+ *         <tr>
+ *             <td>boolean</td>
+ *             <td>boolean</td>
+ *         </tr>
+ *
+ *         <tr>
+ *             <td>null</td>
+ *             <td>null</td>
+ *         </tr>
+ *     </tbody>
+ * </table>
+ *
+ * <p>{@code JsonDeserializer} instances are <em>not</em> thread-safe; only one thread may safely use a single
+ * deserializer at a time.</p>
+ *
+ * @author <a href="https://github.com/jchambers">Jon Chambers</a>
+ *
+ * @since 0.14.0
+ */
+public class JsonDeserializer {
+
+    private final JsonParser jsonParser = new JsonParser(new StringReader(""));
+
+    /**
+     * Parses a JSON string as a JSON object.
+     *
+     * @param jsonString the JSON string to be parsed
+     *
+     * @return a Java representation of the JSON-encoded object
+     *
+     * @throws ParseException if the given {@code jsonString} does not represent a valid JSON object
+     */
+    public Map<String, Object> parseJsonObject(final String jsonString) throws ParseException {
+        jsonParser.ReInit(new StringReader(jsonString));
+        return jsonParser.object();
+    }
+}
diff --git a/pushy/src/main/java/com/eatthepath/json/JsonSerializer.java b/pushy/src/main/java/com/eatthepath/json/JsonSerializer.java
new file mode 100644
index 00000000..fbc24b3d
--- /dev/null
+++ b/pushy/src/main/java/com/eatthepath/json/JsonSerializer.java
@@ -0,0 +1,916 @@
+package com.eatthepath.json;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * <p>A JSON serializer is responsible for producing complete and legal JSON texts (objects or arrays). It can serialize
+ * a {@link Map} as a JSON object or any {@link Collection} or array as a JSON list. The keys of a {@code Map} are
+ * always serialized as strings. Values within a {@code Map}, {@code Collection}, or array are converted to JSON values
+ * as follows:</p>
+ *
+ * <table>
+ *  <caption>Mapping of Java types to JSON types</caption>
+ *  <thead>
+ *      <tr>
+ *          <th>Object type</th>
+ *          <th>JSON representation</th>
+ *      </tr>
+ *  </thead>
+ *  <tbody>
+ *      <tr>
+ *          <td>{@code null}</td>
+ *          <td>{@code null}</td>
+ *      </tr>
+ *
+ *      <tr>
+ *          <td>{@link Map}</td>
+ *          <td>{@code object}</td>
+ *      </tr>
+ *
+ *      <tr>
+ *          <td>{@link Collection}</td>
+ *          <td>{@code list}</td>
+ *      </tr>
+ *
+ *      <tr>
+ *          <td>{@code byte[]}</td>
+ *          <td>{@code list}</td>
+ *      </tr>
+ *
+ *      <tr>
+ *          <td>{@code short[]}</td>
+ *          <td>{@code list}</td>
+ *      </tr>
+ *
+ *      <tr>
+ *          <td>{@code int[]}</td>
+ *          <td>{@code list}</td>
+ *      </tr>
+ *
+ *      <tr>
+ *          <td>{@code long[]}</td>
+ *          <td>{@code list}</td>
+ *      </tr>
+ *
+ *      <tr>
+ *          <td>{@code float[]}</td>
+ *          <td>{@code list}</td>
+ *      </tr>
+ *
+ *      <tr>
+ *          <td>{@code double[]}</td>
+ *          <td>{@code list}</td>
+ *      </tr>
+ *
+ *      <tr>
+ *          <td>{@code boolean[]}</td>
+ *          <td>{@code list}</td>
+ *      </tr>
+ *
+ *      <tr>
+ *          <td>{@code char[]}</td>
+ *          <td>{@code list}</td>
+ *      </tr>
+ *
+ *      <tr>
+ *          <td>{@link Object}{@code []}</td>
+ *          <td>{@code list}</td>
+ *      </tr>
+ *
+ *      <tr>
+ *          <td>{@link Number}</td>
+ *          <td>{@code number} (or {@code null})</td>
+ *      </tr>
+ *
+ *      <tr>
+ *          <td>{@link Boolean}</td>
+ *          <td>{@code true} or {@code false} (or {@code null})</td>
+ *      </tr>
+ *
+ *      <tr>
+ *          <td>all others</td>
+ *          <td>the result of {@code obj.toString()}</td>
+ *      </tr>
+ *  </tbody>
+ * </table>
+ *
+ * @author <a href="https://github.com/jchambers">Jon Chambers</a>
+ *
+ * @see <a href="http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf">ECMA-404 The JSON Data
+ * Interchange Standard</a>
+ */
+public class JsonSerializer {
+
+    /**
+     * Don't allow direct instantiation.
+     *
+     * @throws InstantiationException in all cases
+     */
+    JsonSerializer() throws InstantiationException {
+        throw new InstantiationException("JsonSerializer must not be instantiated directly.");
+    }
+
+    /**
+     * Writes the given {@link Map} as a JSON object to the given {@link Appendable}. Note that the keys of the given
+     * map will be represented as {@code Strings} (via their {@link Object#toString() toString} method) regardless of
+     * their actual type.
+     *
+     * @param map the map to write as a JSON text
+     * @param out the {@code Appendable} to which to write the JSON text
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    public static void writeJsonText(final Map<?, ?> map, final Appendable out) throws IOException {
+        if (map == null) {
+            out.append("null");
+        } else if (map.isEmpty()) {
+            out.append("{}");
+        } else {
+            out.append('{');
+
+            boolean valuesWritten = false;
+
+            for (final Map.Entry<?, ?> entry : map.entrySet()) {
+                if (entry.getKey() == null) {
+                    throw new NullPointerException("JSON object keys must not be null.");
+                }
+
+                if (valuesWritten) {
+                    out.append(',');
+                }
+
+                writeJsonValue(entry.getKey().toString(), out);
+                out.append(':');
+                writeJsonValue(entry.getValue(), out);
+
+                valuesWritten = true;
+            }
+
+            out.append('}');
+        }
+    }
+
+    /**
+     * Returns the given {@link Map} as a JSON string. Note that the keys of the given map will be represented as
+     * {@code Strings} (via their {@link Object#toString() toString} method) regardless of their actual type.
+     *
+     * @param map the map to return as a JSON text
+     */
+    public static String writeJsonTextAsString(final Map<?, ?> map) {
+        final StringBuilder stringBuilder = new StringBuilder();
+
+        try {
+            writeJsonText(map, stringBuilder);
+            return stringBuilder.toString();
+        } catch (final IOException e) {
+            // This should never happen for a StringBuilder
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Writes the given {@link Collection} as a JSON list to the given {@link Appendable}.
+     *
+     * @param collection the collection to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON text
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    public static void writeJsonText(final Collection<?> collection, final Appendable out) throws IOException {
+        if (collection == null) {
+            out.append("null");
+        } else if (collection.isEmpty()) {
+            out.append("[]");
+        } else {
+            out.append('[');
+
+            boolean valuesWritten = false;
+
+            for (final Object o : collection) {
+                if (valuesWritten) {
+                    out.append(',');
+                }
+
+                writeJsonValue(o, out);
+
+                valuesWritten = true;
+            }
+
+            out.append(']');
+        }
+    }
+
+    /**
+     * Returns the given {@link Collection} as a JSON string.
+     *
+     * @param collection the collection to return as a JSON text
+     */
+    public static String writeJsonTextAsString(final Collection<?> collection) {
+        final StringBuilder stringBuilder = new StringBuilder();
+
+        try {
+            writeJsonText(collection, stringBuilder);
+            return stringBuilder.toString();
+        } catch (final IOException e) {
+            // This should never happen for a StringBuilder
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Writes the given array of {@code bytes} as a JSON list to the given {@link Appendable}.
+     *
+     * @param array the array to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON text
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    public static void writeJsonText(final byte[] array, final Appendable out) throws IOException {
+        if (array == null) {
+            out.append("null");
+        } else if (array.length == 0) {
+            out.append("[]");
+        } else {
+            out.append('[');
+            out.append(String.valueOf(array[0]));
+
+            for (int i = 1; i < array.length; i++) {
+                out.append(',');
+                out.append(String.valueOf(array[i]));
+            }
+
+            out.append(']');
+        }
+    }
+
+    /**
+     * Returns the given array of {@code bytes} as a JSON string.
+     *
+     * @param array the array to return as a JSON text
+     */
+    public static String writeJsonTextAsString(final byte[] array) {
+        final StringBuilder stringBuilder = new StringBuilder();
+
+        try {
+            writeJsonText(array, stringBuilder);
+            return stringBuilder.toString();
+        } catch (final IOException e) {
+            // This should never happen for a StringBuilder
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Writes the given array of {@code shorts} as a JSON list to the given {@link Appendable}.
+     *
+     * @param array the array to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON text
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    public static void writeJsonText(final short[] array, final Appendable out) throws IOException {
+        if (array == null) {
+            out.append("null");
+        } else if (array.length == 0) {
+            out.append("[]");
+        } else {
+            out.append('[');
+            out.append(String.valueOf(array[0]));
+
+            for (int i = 1; i < array.length; i++) {
+                out.append(',');
+                out.append(String.valueOf(array[i]));
+            }
+
+            out.append(']');
+        }
+    }
+
+    /**
+     * Returns the given array of {@code shorts} as a JSON string.
+     *
+     * @param array the array to return as a JSON text
+     */
+    public static String writeJsonTextAsString(final short[] array) {
+        final StringBuilder stringBuilder = new StringBuilder();
+
+        try {
+            writeJsonText(array, stringBuilder);
+            return stringBuilder.toString();
+        } catch (final IOException e) {
+            // This should never happen for a StringBuilder
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Writes the given array of {@code ints} as a JSON list to the given {@link Appendable}.
+     *
+     * @param array the array to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON text
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    public static void writeJsonText(final int[] array, final Appendable out) throws IOException {
+        if (array == null) {
+            out.append("null");
+        } else if (array.length == 0) {
+            out.append("[]");
+        } else {
+            out.append('[');
+            out.append(String.valueOf(array[0]));
+
+            for (int i = 1; i < array.length; i++) {
+                out.append(',');
+                out.append(String.valueOf(array[i]));
+            }
+
+            out.append(']');
+        }
+    }
+
+    /**
+     * Returns the given array of {@code ints} as a JSON string.
+     *
+     * @param array the array to return as a JSON text
+     */
+    public static String writeJsonTextAsString(final int[] array) {
+        final StringBuilder stringBuilder = new StringBuilder();
+
+        try {
+            writeJsonText(array, stringBuilder);
+            return stringBuilder.toString();
+        } catch (final IOException e) {
+            // This should never happen for a StringBuilder
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Writes the given array of {@code longs} as a JSON list to the given {@link Appendable}.
+     *
+     * @param array the array to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON text
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    public static void writeJsonText(final long[] array, final Appendable out) throws IOException {
+        if (array == null) {
+            out.append("null");
+        } else if (array.length == 0) {
+            out.append("[]");
+        } else {
+            out.append('[');
+            out.append(String.valueOf(array[0]));
+
+            for (int i = 1; i < array.length; i++) {
+                out.append(',');
+                out.append(String.valueOf(array[i]));
+            }
+
+            out.append(']');
+        }
+    }
+
+    /**
+     * Returns the given array of {@code longs} as a JSON string.
+     *
+     * @param array the array to return as a JSON text
+     */
+    public static String writeJsonTextAsString(final long[] array) {
+        final StringBuilder stringBuilder = new StringBuilder();
+
+        try {
+            writeJsonText(array, stringBuilder);
+            return stringBuilder.toString();
+        } catch (final IOException e) {
+            // This should never happen for a StringBuilder
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Writes the given array of {@code floats} as a JSON list to the given {@link Appendable}.
+     *
+     * @param array the array to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON text
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    public static void writeJsonText(final float[] array, final Appendable out) throws IOException {
+        if (array == null) {
+            out.append("null");
+        } else if (array.length == 0) {
+            out.append("[]");
+        } else {
+            out.append('[');
+            out.append(String.valueOf(array[0]));
+
+            for (int i = 1; i < array.length; i++) {
+                out.append(',');
+                out.append(String.valueOf(array[i]));
+            }
+
+            out.append(']');
+        }
+    }
+
+    /**
+     * Returns the given array of {@code floats} as a JSON string.
+     *
+     * @param array the array to return as a JSON text
+     */
+    public static String writeJsonTextAsString(final float[] array) {
+        final StringBuilder stringBuilder = new StringBuilder();
+
+        try {
+            writeJsonText(array, stringBuilder);
+            return stringBuilder.toString();
+        } catch (final IOException e) {
+            // This should never happen for a StringBuilder
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Writes the given array of {@code doubles} as a JSON list to the given {@link Appendable}.
+     *
+     * @param array the array to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON text
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    public static void writeJsonText(final double[] array, final Appendable out) throws IOException {
+        if (array == null) {
+            out.append("null");
+        } else if (array.length == 0) {
+            out.append("[]");
+        } else {
+            out.append('[');
+            out.append(String.valueOf(array[0]));
+
+            for (int i = 1; i < array.length; i++) {
+                out.append(',');
+                out.append(String.valueOf(array[i]));
+            }
+
+            out.append(']');
+        }
+    }
+
+    /**
+     * Returns the given array of {@code doubles} as a JSON string.
+     *
+     * @param array the array to return as a JSON text
+     */
+    public static String writeJsonTextAsString(final double[] array) {
+        final StringBuilder stringBuilder = new StringBuilder();
+
+        try {
+            writeJsonText(array, stringBuilder);
+            return stringBuilder.toString();
+        } catch (final IOException e) {
+            // This should never happen for a StringBuilder
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Writes the given array of {@code booleans} as a JSON list to the given {@link Appendable}.
+     *
+     * @param array the array to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON text
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    public static void writeJsonText(final boolean[] array, final Appendable out) throws IOException {
+        if (array == null) {
+            out.append("null");
+        } else if (array.length == 0) {
+            out.append("[]");
+        } else {
+            out.append('[');
+            out.append(String.valueOf(array[0]));
+
+            for (int i = 1; i < array.length; i++) {
+                out.append(',');
+                out.append(String.valueOf(array[i]));
+            }
+
+            out.append(']');
+        }
+    }
+
+    /**
+     * Returns the given array of {@code booleans} as a JSON string.
+     *
+     * @param array the array to return as a JSON text
+     */
+    public static String writeJsonTextAsString(final boolean[] array) {
+        final StringBuilder stringBuilder = new StringBuilder();
+
+        try {
+            writeJsonText(array, stringBuilder);
+            return stringBuilder.toString();
+        } catch (final IOException e) {
+            // This should never happen for a StringBuilder
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Writes the given array of characters as a JSON list to the given {@link Appendable}.
+     *
+     * @param array the array to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON text
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    public static void writeJsonText(final char[] array, final Appendable out) throws IOException {
+        if (array == null) {
+            out.append("null");
+        } else if (array.length == 0) {
+            out.append("[]");
+        } else {
+            out.append('[');
+            writeJsonValue(array[0], out);
+
+            for (int i = 1; i < array.length; i++) {
+                out.append(',');
+                writeJsonValue(array[i], out);
+            }
+
+            out.append(']');
+        }
+    }
+
+    /**
+     * Returns the given array of {@code chars} as a JSON string.
+     *
+     * @param array the array to return as a JSON text
+     */
+    public static String writeJsonTextAsString(final char[] array) {
+        final StringBuilder stringBuilder = new StringBuilder();
+
+        try {
+            writeJsonText(array, stringBuilder);
+            return stringBuilder.toString();
+        } catch (final IOException e) {
+            // This should never happen for a StringBuilder
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Writes the given array of {@link Object Objects} as a JSON list to the given {@link Appendable}.
+     *
+     * @param array the array to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON text
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    public static void writeJsonText(final Object[] array, final Appendable out) throws IOException {
+        if (array == null) {
+            out.append("null");
+        } else if (array.length == 0) {
+            out.append("[]");
+        } else {
+            out.append('[');
+            writeJsonValue(array[0], out);
+
+            for (int i = 1; i < array.length; i++) {
+                out.append(',');
+                writeJsonValue(array[i], out);
+            }
+
+            out.append(']');
+        }
+    }
+
+    /**
+     * Returns the given array of {@code Objects} as a JSON string.
+     *
+     * @param array the array to return as a JSON text
+     */
+    public static String writeJsonTextAsString(final Object[] array) {
+        final StringBuilder stringBuilder = new StringBuilder();
+
+        try {
+            writeJsonText(array, stringBuilder);
+            return stringBuilder.toString();
+        } catch (final IOException e) {
+            // This should never happen for a StringBuilder
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Writes the given object as a JSON value.
+     *
+     * @param o the object to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON value
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    static void writeJsonValue(final Object o, final Appendable out) throws IOException {
+        if (o == null) {
+            out.append("null");
+        } else if (o instanceof Optional) {
+            final Optional<?> optional = (Optional<?>) o;
+
+            if (optional.isPresent()) {
+                writeJsonValue(optional.get(), out);
+            } else {
+                writeJsonValue((Object) null, out);
+            }
+        } else if (o instanceof Map) {
+            writeJsonText((Map<?, ?>) o, out);
+        } else if (o instanceof Collection) {
+            writeJsonText((Collection<?>) o, out);
+        } else if (o instanceof byte[]) {
+            writeJsonText((byte[]) o, out);
+        } else if (o instanceof short[]) {
+            writeJsonText((short[]) o, out);
+        } else if (o instanceof int[]) {
+            writeJsonText((int[]) o, out);
+        } else if (o instanceof long[]) {
+            writeJsonText((long[]) o, out);
+        } else if (o instanceof float[]) {
+            writeJsonText((float[]) o, out);
+        } else if (o instanceof double[]) {
+            writeJsonText((double[]) o, out);
+        } else if (o instanceof boolean[]) {
+            writeJsonText((boolean[]) o, out);
+        } else if (o instanceof char[]) {
+            writeJsonText((char[]) o, out);
+        } else if (o instanceof Object[]) {
+            writeJsonText((Object[]) o, out);
+        } else if (o instanceof Number) {
+            writeJsonValue((Number) o, out);
+        } else if (o instanceof Boolean) {
+            writeJsonValue((Boolean) o, out);
+        } else {
+            writeJsonValue(o.toString(), out);
+        }
+    }
+
+    /**
+     * Writes the given {@link String} as a JSON string.
+     *
+     * @param string the string to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON value
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    static void writeJsonValue(final String string, final Appendable out) throws IOException {
+
+        if (string == null) {
+            out.append("null");
+        } else if ("".equals(string)) {
+            out.append("\"\"");
+        } else {
+
+            out.append('\"');
+
+            int start = 0;
+
+            // The strategy here is to make as few writes as possible by looking for substrings of characters that don't
+            // need to be escaped. Whenever we hit a character that needs to be escaped, we write all of the
+            // previously-unwritten characters up to that point, then add the escaped version of that character.
+            for (int i = 0; i < string.length(); i++) {
+                final char c = string.charAt(i);
+
+                if (c == '"' || c == '\\' || c == '/' || c == '\b' || c == '\f' || c == '\n' || c == '\r' || c == '\t' ||
+                        Character.isISOControl(c)) {
+
+                    out.append(string, start, i);
+                    appendEscapedCharacter(c, out);
+
+                    start = i + 1;
+                }
+            }
+
+            if (start == 0) {
+                // We hit the end of the string without appending anything
+                out.append(string);
+            } else if (start < string.length()) {
+                out.append(string, start, string.length());
+            }
+
+            out.append('\"');
+        }
+    }
+
+    /**
+     * Writes the given {@code char} as a JSON string.
+     *
+     * @param c the character to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON value
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    static void writeJsonValue(final char c, final Appendable out) throws IOException {
+        out.append('"');
+        appendEscapedCharacter(c, out);
+        out.append('"');
+    }
+
+    /**
+     * Appends the given {@code char} to the given {@link Appendable}, escaping it if necessary.
+     *
+     * @param c the character to append
+     * @param out the {@code Appendable} to which to write the given character
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    private static void appendEscapedCharacter(final char c, final Appendable out) throws IOException {
+        switch (c) {
+            case '"':
+                out.append("\\\"");
+                break;
+
+            case '\\':
+                out.append("\\\\");
+                break;
+
+            case '/':
+                out.append("\\/");
+                break;
+
+            case '\b':
+                out.append("\\b");
+                break;
+
+            case '\f':
+                out.append("\\f");
+                break;
+
+            case '\n':
+                out.append("\\n");
+                break;
+
+            case '\r':
+                out.append("\\r");
+                break;
+
+            case '\t':
+                out.append("\\t");
+                break;
+
+            default:
+                if (Character.isISOControl(c)) {
+                    out.append("\\u");
+                    out.append(Integer.toHexString(0x10000 | c), 1, 5);
+                } else {
+                    out.append(c);
+                }
+
+                break;
+        }
+    }
+
+    /**
+     * Writes the given {@link Number} as a JSON number.
+     *
+     * @param number the number to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON value
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    static void writeJsonValue(final Number number, final Appendable out) throws IOException {
+        if (number == null) {
+            out.append("null");
+        } else if (number instanceof Float) {
+            // Floats can have NaN or infinite values; pass them off to a dedicated handler
+            writeJsonValue(number.floatValue(), out);
+        } else if (number instanceof Double) {
+            // Doubles can have NaN or infinite values; pass them off to a dedicated handler
+            writeJsonValue(number.doubleValue(), out);
+        } else {
+            out.append(number.toString());
+        }
+    }
+
+    /**
+     * Writes the given {@code byte} as a JSON number.
+     *
+     * @param b the byte to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON value
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    static void writeJsonValue(final byte b, final Appendable out) throws IOException {
+        out.append(Byte.toString(b));
+    }
+
+    /**
+     * Writes the given {@code short} as a JSON number.
+     *
+     * @param s the short to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON value
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    static void writeJsonValue(final short s, final Appendable out) throws IOException {
+        out.append(Short.toString(s));
+    }
+
+    /**
+     * Writes the given {@code int} as a JSON number.
+     *
+     * @param i the integer to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON value
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    static void writeJsonValue(final int i, final Appendable out) throws IOException {
+        out.append(Integer.toString(i));
+    }
+
+    /**
+     * Writes the given {@code long} as a JSON number.
+     *
+     * @param l the long to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON value
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    static void writeJsonValue(final long l, final Appendable out) throws IOException {
+        out.append(Long.toString(l));
+    }
+
+    /**
+     * Writes the given {@code float} as a JSON number. Note that {@code NaN} values and non-finite numbers cannot be
+     * written as JSON values.
+     *
+     * @param f the float to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON value
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     * @throws IllegalArgumentException if the given float was not a number or was not finite
+     */
+    static void writeJsonValue(final float f, final Appendable out) throws IOException {
+        if (Float.isInfinite(f)) {
+            throw new IllegalArgumentException("Cannot write non-finite numbers as JSON values.");
+        }
+
+        if (Float.isNaN(f)) {
+            throw new IllegalArgumentException("Cannot write NaN as a JSON value.");
+        }
+
+        out.append(Float.toString(f));
+    }
+
+    /**
+     * Writes the given {@code double} as a JSON number. Note that {@code NaN} values and non-finite numbers cannot be
+     * written as JSON values.
+     *
+     * @param d the double to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON value
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     * @throws IllegalArgumentException if the given double was not a number or was not finite
+     */
+    static void writeJsonValue(final double d, final Appendable out) throws IOException {
+        if (Double.isInfinite(d)) {
+            throw new IllegalArgumentException("Cannot write non-finite numbers as JSON values.");
+        }
+
+        if (Double.isNaN(d)) {
+            throw new IllegalArgumentException("Cannot write NaN as a JSON value.");
+        }
+
+        out.append(Double.toString(d));
+    }
+
+    /**
+     * Writes the given {@link Boolean} as a JSON value.
+     *
+     * @param b the boolean to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON value
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    static void writeJsonValue(final Boolean b, final Appendable out) throws IOException {
+        if (b == null) {
+            out.append("null");
+        } else {
+            writeJsonValue(b.booleanValue(), out);
+        }
+    }
+
+    /**
+     * Writes the given {@code boolean} as a JSON value.
+     *
+     * @param b the boolean to write as a JSON value
+     * @param out the {@code Appendable} to which to write the JSON value
+     *
+     * @throws IOException in the event of an I/O error when writing to the given {@code Appendable}
+     */
+    static void writeJsonValue(final boolean b, final Appendable out) throws IOException {
+        out.append(b ? "true" : "false");
+    }
+}
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java
index e41f7161..726877ab 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java
@@ -22,11 +22,10 @@
 
 package com.eatthepath.pushy.apns;
 
-import com.eatthepath.pushy.apns.util.InstantAsTimeSinceEpochTypeAdapter;
+import com.eatthepath.json.JsonDeserializer;
+import com.eatthepath.json.ParseException;
 import com.eatthepath.pushy.apns.util.concurrent.PushNotificationFuture;
 import com.eatthepath.uuid.FastUUID;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
 import io.netty.buffer.ByteBuf;
 import io.netty.buffer.Unpooled;
 import io.netty.channel.Channel;
@@ -50,7 +49,6 @@ import org.slf4j.LoggerFactory;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
-import java.time.Instant;
 import java.util.Map;
 import java.util.Objects;
 import java.util.UUID;
@@ -86,9 +84,7 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
     private static final IOException STREAM_CLOSED_BEFORE_REPLY_EXCEPTION =
             new IOException("Stream closed before a reply was received");
 
-    private static final Gson GSON = new GsonBuilder()
-            .registerTypeAdapter(Instant.class, new InstantAsTimeSinceEpochTypeAdapter(TimeUnit.MILLISECONDS))
-            .create();
+    private final JsonDeserializer jsonDeserializer = new JsonDeserializer();
 
     private static final Logger log = LoggerFactory.getLogger(ApnsClientHandler.class);
 
@@ -332,7 +328,16 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
                     true, getApnsIdFromHeaders(headers), null, null));
         } else {
             if (data != null) {
-                final ErrorResponse errorResponse = GSON.fromJson(data.toString(StandardCharsets.UTF_8), ErrorResponse.class);
+                ErrorResponse errorResponse;
+
+                try {
+                    errorResponse = ErrorResponse.fromMap(
+                            jsonDeserializer.parseJsonObject(data.toString(StandardCharsets.UTF_8)));
+                } catch (final ParseException e) {
+                    log.error("Failed to parse error response: {}", data.toString(StandardCharsets.UTF_8));
+                    errorResponse = new ErrorResponse(null, null);
+                }
+
                 this.handleErrorResponse(context, stream.id(), headers, pushNotification, errorResponse);
             } else {
                 log.warn("Gateway sent an end-of-stream HEADERS frame for an unsuccessful notification.");
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/ErrorResponse.java b/pushy/src/main/java/com/eatthepath/pushy/apns/ErrorResponse.java
index cd13f6c2..7e7ec019 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/ErrorResponse.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ErrorResponse.java
@@ -23,6 +23,7 @@
 package com.eatthepath.pushy.apns;
 
 import java.time.Instant;
+import java.util.Map;
 
 class ErrorResponse {
     private final String reason;
@@ -33,6 +34,30 @@ class ErrorResponse {
         this.timestamp = timestamp;
     }
 
+    static ErrorResponse fromMap(final Map<String, Object> errorResponseMap) {
+        String reason;
+
+        try {
+            reason = (String) errorResponseMap.get("reason");
+        } catch (final ClassCastException e) {
+            reason = null;
+        }
+
+        Instant timestamp;
+
+        if (errorResponseMap.containsKey("timestamp")) {
+            try {
+                timestamp = Instant.ofEpochMilli((Long) errorResponseMap.get("timestamp"));
+            } catch (final ClassCastException e) {
+                timestamp = null;
+            }
+        } else {
+            timestamp = null;
+        }
+
+        return new ErrorResponse(reason, timestamp);
+    }
+
     String getReason() {
         return this.reason;
     }
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/auth/AuthenticationToken.java b/pushy/src/main/java/com/eatthepath/pushy/apns/auth/AuthenticationToken.java
index 09b98107..16a31ba5 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/auth/AuthenticationToken.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/auth/AuthenticationToken.java
@@ -22,10 +22,9 @@
 
 package com.eatthepath.pushy.apns.auth;
 
-import com.eatthepath.pushy.apns.util.InstantAsTimeSinceEpochTypeAdapter;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.gson.annotations.SerializedName;
+import com.eatthepath.json.JsonDeserializer;
+import com.eatthepath.json.JsonSerializer;
+import com.eatthepath.json.ParseException;
 import io.netty.buffer.ByteBuf;
 import io.netty.buffer.Unpooled;
 import io.netty.handler.codec.base64.Base64;
@@ -38,8 +37,9 @@ import java.security.NoSuchAlgorithmException;
 import java.security.Signature;
 import java.security.SignatureException;
 import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.Objects;
-import java.util.concurrent.TimeUnit;
 
 /**
  * <p>An authentication token (or "provider authentication token" or "provider token" in Apple's terminology) is a
@@ -65,31 +65,39 @@ import java.util.concurrent.TimeUnit;
  */
 public class AuthenticationToken {
 
-    private static class AuthenticationTokenHeader {
-        @SerializedName("alg")
-        private final String algorithm = "ES256";
+    static class AuthenticationTokenHeader {
 
-        @SerializedName("typ")
-        private final String tokenType = "JWT";
-
-        @SerializedName("kid")
         private final String keyId;
 
         AuthenticationTokenHeader(final String keyId) {
             this.keyId = keyId;
         }
 
+        static AuthenticationTokenHeader fromMap(final Map<String, Object> headerMap) {
+            if (!headerMap.containsKey("kid") || !(headerMap.get("kid") instanceof String)) {
+                throw new IllegalArgumentException("Header map must map a string value to the \"kid\" key.");
+            }
+
+            return new AuthenticationTokenHeader((String) headerMap.get("kid"));
+        }
+
         String getKeyId() {
             return this.keyId;
         }
+
+        Map<String, Object> toMap() {
+            final Map<String, Object> headerMap = new HashMap<>(3, 1);
+            headerMap.put("alg", "ES256");
+            headerMap.put("typ", "JWT");
+            headerMap.put("kid", this.keyId);
+
+            return headerMap;
+        }
     }
 
-    private static class AuthenticationTokenClaims {
+    static class AuthenticationTokenClaims {
 
-        @SerializedName("iss")
         private final String issuer;
-
-        @SerializedName("iat")
         private final Instant issuedAt;
 
         AuthenticationTokenClaims(final String teamId, final Instant issuedAt) {
@@ -97,6 +105,21 @@ public class AuthenticationToken {
             this.issuedAt = issuedAt;
         }
 
+        static AuthenticationTokenClaims fromMap(final Map<String, Object> claimsMap) {
+            if (!claimsMap.containsKey("iss") || !(claimsMap.get("iss") instanceof String)) {
+                throw new IllegalArgumentException("Claims map must map a string value to the \"iss\" key.");
+            }
+
+            if (!claimsMap.containsKey("iat") || !(claimsMap.get("iat") instanceof Long)) {
+                throw new IllegalArgumentException("Claims map must map a long value to the \"iat\" key.");
+            }
+
+            final String teamId = (String) claimsMap.get("iss");
+            final Instant issuedAt = Instant.ofEpochSecond((Long) claimsMap.get("iat"));
+
+            return new AuthenticationTokenClaims(teamId, issuedAt);
+        }
+
         String getIssuer() {
             return this.issuer;
         }
@@ -104,12 +127,15 @@ public class AuthenticationToken {
         Instant getIssuedAt() {
             return this.issuedAt;
         }
-    }
 
-    private static final Gson GSON = new GsonBuilder()
-            .disableHtmlEscaping()
-            .registerTypeAdapter(Instant.class, new InstantAsTimeSinceEpochTypeAdapter(TimeUnit.SECONDS))
-            .create();
+        Map<String, Object> toMap() {
+            final Map<String, Object> headerMap = new HashMap<>(2, 1);
+            headerMap.put("iss", this.issuer);
+            headerMap.put("iat", this.issuedAt.getEpochSecond());
+
+            return headerMap;
+        }
+    }
 
     private final AuthenticationTokenHeader header;
     private final AuthenticationTokenClaims claims;
@@ -134,8 +160,8 @@ public class AuthenticationToken {
         this.header = new AuthenticationTokenHeader(signingKey.getKeyId());
         this.claims = new AuthenticationTokenClaims(signingKey.getTeamId(), issuedAt);
 
-        final String headerJson = GSON.toJson(this.header);
-        final String claimsJson = GSON.toJson(this.claims);
+        final String headerJson = JsonSerializer.writeJsonTextAsString(this.header.toMap());
+        final String claimsJson = JsonSerializer.writeJsonTextAsString(this.claims.toMap());
 
         final StringBuilder payloadBuilder = new StringBuilder();
         payloadBuilder.append(encodeUnpaddedBase64UrlString(headerJson.getBytes(StandardCharsets.US_ASCII)));
@@ -175,8 +201,26 @@ public class AuthenticationToken {
             throw new IllegalArgumentException();
         }
 
-        this.header = GSON.fromJson(new String(decodeBase64UrlEncodedString(jwtSegments[0]), StandardCharsets.US_ASCII), AuthenticationTokenHeader.class);
-        this.claims = GSON.fromJson(new String(decodeBase64UrlEncodedString(jwtSegments[1]), StandardCharsets.US_ASCII), AuthenticationTokenClaims.class);
+        final JsonDeserializer jsonDeserializer = new JsonDeserializer();
+
+        try {
+            this.header = AuthenticationTokenHeader.fromMap(
+                    jsonDeserializer.parseJsonObject(
+                            new String(decodeBase64UrlEncodedString(jwtSegments[0]), StandardCharsets.US_ASCII)));
+        } catch (final ParseException e) {
+            throw new IllegalArgumentException("Could not parse header as a JSON object: " +
+                    new String(decodeBase64UrlEncodedString(jwtSegments[0]), StandardCharsets.US_ASCII));
+        }
+
+        try {
+            this.claims = AuthenticationTokenClaims.fromMap(
+                    jsonDeserializer.parseJsonObject(
+                            new String(decodeBase64UrlEncodedString(jwtSegments[1]), StandardCharsets.US_ASCII)));
+        } catch (final ParseException e) {
+            throw new IllegalArgumentException("Could not parse claims as a JSON object: " +
+                    new String(decodeBase64UrlEncodedString(jwtSegments[1]), StandardCharsets.US_ASCII));
+        }
+
         this.signatureBytes = decodeBase64UrlEncodedString(jwtSegments[2]);
     }
 
@@ -230,8 +274,8 @@ public class AuthenticationToken {
 
         final byte[] headerAndClaimsBytes;
 
-        final String headerJson = GSON.toJson(this.header);
-        final String claimsJson = GSON.toJson(this.claims);
+        final String headerJson = JsonSerializer.writeJsonTextAsString(this.header.toMap());
+        final String claimsJson = JsonSerializer.writeJsonTextAsString(this.claims.toMap());
 
         final String encodedHeaderAndClaims =
                 encodeUnpaddedBase64UrlString(headerJson.getBytes(StandardCharsets.US_ASCII)) + '.' +
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServerHandler.java b/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServerHandler.java
index 1632a51d..59d3ac6b 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServerHandler.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServerHandler.java
@@ -22,10 +22,8 @@
 
 package com.eatthepath.pushy.apns.server;
 
-import com.eatthepath.pushy.apns.util.InstantAsTimeSinceEpochTypeAdapter;
+import com.eatthepath.json.JsonSerializer;
 import com.eatthepath.uuid.FastUUID;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
 import io.netty.buffer.ByteBuf;
 import io.netty.buffer.Unpooled;
 import io.netty.channel.ChannelFuture;
@@ -40,8 +38,9 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.UUID;
-import java.util.concurrent.TimeUnit;
 
 class MockApnsServerHandler extends Http2ConnectionHandler implements Http2FrameListener {
 
@@ -55,10 +54,6 @@ class MockApnsServerHandler extends Http2ConnectionHandler implements Http2Frame
 
     private static final int MAX_CONTENT_LENGTH = 4096;
 
-    private static final Gson GSON = new GsonBuilder()
-            .registerTypeAdapter(Instant.class, new InstantAsTimeSinceEpochTypeAdapter(TimeUnit.MILLISECONDS))
-            .create();
-
     private static final Logger log = LoggerFactory.getLogger(MockApnsServerHandler.class);
 
     public static class MockApnsServerHandlerBuilder extends AbstractHttp2ConnectionHandlerBuilder<MockApnsServerHandler, MockApnsServerHandler.MockApnsServerHandlerBuilder> {
@@ -317,11 +312,14 @@ class MockApnsServerHandler extends Http2ConnectionHandler implements Http2Frame
 
             final byte[] payloadBytes;
             {
-                final ErrorPayload errorPayload =
-                        new ErrorPayload(rejectNotificationResponse.getErrorReason().getReasonText(),
-                                rejectNotificationResponse.getTimestamp());
+                final Map<String, Object> errorPayload = new HashMap<>(2, 1);
+                errorPayload.put("reason", rejectNotificationResponse.getErrorReason().getReasonText());
+
+                if (rejectNotificationResponse.getTimestamp() != null) {
+                    errorPayload.put("timestamp", rejectNotificationResponse.timestamp.toEpochMilli());
+                }
 
-                payloadBytes = GSON.toJson(errorPayload).getBytes();
+                payloadBytes = JsonSerializer.writeJsonTextAsString(errorPayload).getBytes();
             }
 
             final ChannelPromise headersPromise = context.newPromise();
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilder.java b/pushy/src/main/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilder.java
index 0cf33972..c78cc5bd 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilder.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilder.java
@@ -22,8 +22,7 @@
 
 package com.eatthepath.pushy.apns.util;
 
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
+import com.eatthepath.json.JsonSerializer;
 
 import java.util.*;
 
@@ -58,7 +57,7 @@ public class ApnsPayloadBuilder {
     private Integer badgeNumber = null;
 
     private String soundFileName = null;
-    private SoundForCriticalAlert soundForCriticalAlert = null;
+    private Map<String, Object> soundForCriticalAlert = null;
 
     private String categoryName = null;
 
@@ -73,6 +72,8 @@ public class ApnsPayloadBuilder {
 
     private String[] urlArguments = null;
 
+    private final HashMap<String, Object> customProperties = new HashMap<>();
+
     private boolean preferStringRepresentationForAlerts = false;
 
     private static final String APS_KEY = "aps";
@@ -103,22 +104,7 @@ public class ApnsPayloadBuilder {
 
     private static final String MDM_KEY = "mdm";
 
-    private final HashMap<String, Object> customProperties = new HashMap<>();
-
-    private static final Gson GSON = new GsonBuilder().serializeNulls().disableHtmlEscaping().create();
-
-    @SuppressWarnings({"FieldCanBeLocal", "unused"})
-    private static class SoundForCriticalAlert {
-        private final String name;
-        private final int critical;
-        private final double volume;
-
-        private SoundForCriticalAlert(final String name, final boolean critical, final double volume) {
-            this.name = name;
-            this.critical = critical ? 1 : 0;
-            this.volume = volume;
-        }
-    }
+    public static final String[] EMPTY_STRING_ARRAY = new String[0];
 
     /**
      * The name of the iOS default push notification sound.
@@ -466,11 +452,21 @@ public class ApnsPayloadBuilder {
         }
 
         this.soundFileName = null;
-        this.soundForCriticalAlert = new SoundForCriticalAlert(soundFileName, isCriticalAlert, soundVolume);
+        this.soundForCriticalAlert = buildSoundForCriticalAlertMap(soundFileName, isCriticalAlert, soundVolume);
 
         return this;
     }
 
+    private static Map<String, Object> buildSoundForCriticalAlertMap(final String name, final boolean critical, final double volume) {
+        final Map<String, Object> soundForCriticalAlertMap = new HashMap<>(3, 1);
+
+        soundForCriticalAlertMap.put("name", name);
+        soundForCriticalAlertMap.put("critical", critical ? 1 : 0);
+        soundForCriticalAlertMap.put("volume", volume);
+
+        return soundForCriticalAlertMap;
+    }
+
     /**
      * <p>Sets whether the payload under construction should contain a flag that indicates that new content is available
      * to be downloaded in the background by the receiving app. By default, no content availability flag is included
@@ -630,7 +626,7 @@ public class ApnsPayloadBuilder {
      * @since 0.8.2
      */
     public ApnsPayloadBuilder setUrlArguments(final List<String> arguments) {
-        return this.setUrlArguments(arguments != null ? arguments.toArray(new String[0]) : null);
+        return this.setUrlArguments(arguments != null ? arguments.toArray(EMPTY_STRING_ARRAY) : null);
     }
 
     /**
@@ -671,18 +667,12 @@ public class ApnsPayloadBuilder {
      * identifying when the provider sent the notification. Any action associated with an alert message should not be
      * destructive—for example, it should not delete data on the device.</blockquote>
      *
-     * <p>The value for the property is serialized to JSON by <a href="https://github.com/google/gson">Gson</a>. For
-     * a detailed explanation of how Gson serializes Java objects to JSON, please see the
-     * <a href="https://github.com/google/gson/blob/master/UserGuide.md#TOC-Using-Gson">Gson User Guide</a>.</p>
+     * <p>The value for the property is serialized to JSON as described in {@link JsonSerializer}.</p>
      *
      * @param key the key of the custom property in the payload object
      * @param value the value of the custom property
      *
      * @return a reference to this payload builder
-     *
-     * @see Gson#toJson(Object)
-     * @see <a href="https://github.com/google/gson/blob/master/UserGuide.md#TOC-Using-Gson">Gson User Guide - Using
-     * Gson</a>
      */
     public ApnsPayloadBuilder addCustomProperty(final String key, final Object value) {
         this.customProperties.put(key, value);
@@ -811,7 +801,7 @@ public class ApnsPayloadBuilder {
             payload.put(entry.getKey(), entry.getValue());
         }
 
-        return GSON.toJson(payload);
+        return JsonSerializer.writeJsonTextAsString(payload);
     }
 
     /**
@@ -830,6 +820,6 @@ public class ApnsPayloadBuilder {
      * @since 0.12
      */
     public static String buildMdmPayload(final String pushMagicValue) {
-        return GSON.toJson(java.util.Collections.singletonMap(MDM_KEY, pushMagicValue));
+        return JsonSerializer.writeJsonTextAsString(Collections.singletonMap(MDM_KEY, pushMagicValue));
     }
 }
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/util/InstantAsTimeSinceEpochTypeAdapter.java b/pushy/src/main/java/com/eatthepath/pushy/apns/util/InstantAsTimeSinceEpochTypeAdapter.java
deleted file mode 100644
index 436cbfe0..00000000
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/util/InstantAsTimeSinceEpochTypeAdapter.java
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * Copyright (c) 2020 Jon Chambers
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- */
-
-package com.eatthepath.pushy.apns.util;
-
-import com.google.gson.*;
-
-import java.lang.reflect.Type;
-import java.time.Instant;
-import java.util.Objects;
-import java.util.concurrent.TimeUnit;
-
-/**
- * Converts {@link java.time.Instant} instances in JSON objects to or from timestamps since the epoch.
- *
- * @author <a href="https://github.com/jchambers">Jon Chambers</a>
- */
-public class InstantAsTimeSinceEpochTypeAdapter implements JsonSerializer<Instant>, JsonDeserializer<Instant> {
-
-    private final TimeUnit timeUnit;
-
-    /**
-     * Constructs a new date/timestamp type adapter that uses the given time unit to measure time since the epoch.
-     *
-     * @param timeUnit the time unit in which to express timestamps
-     */
-    public InstantAsTimeSinceEpochTypeAdapter(final TimeUnit timeUnit) {
-        Objects.requireNonNull(timeUnit);
-        this.timeUnit = timeUnit;
-    }
-
-    @Override
-    public Instant deserialize(final JsonElement json, final Type typeOfT, final JsonDeserializationContext context) throws JsonParseException {
-        final Instant instant;
-
-        if (json.isJsonPrimitive()) {
-            instant = Instant.ofEpochMilli(this.timeUnit.toMillis(json.getAsLong()));
-        } else if (json.isJsonNull()) {
-            instant = null;
-        } else {
-            throw new JsonParseException("Dates represented as time since the epoch must either be numbers or null.");
-        }
-
-        return instant;
-    }
-
-    @Override
-    public JsonElement serialize(final Instant src, final Type typeOfSrc, final JsonSerializationContext context) {
-        final JsonElement element;
-
-        if (src != null) {
-            element = new JsonPrimitive(this.timeUnit.convert(src.toEpochMilli(), TimeUnit.MILLISECONDS));
-        } else {
-            element = JsonNull.INSTANCE;
-        }
-
-        return element;
-    }
-}
diff --git a/pushy/src/main/javacc/JsonParser.jj b/pushy/src/main/javacc/JsonParser.jj
new file mode 100644
index 00000000..8d30f228
--- /dev/null
+++ b/pushy/src/main/javacc/JsonParser.jj
@@ -0,0 +1,138 @@
+options {
+    CHOICE_AMBIGUITY_CHECK = 1;
+    SUPPORT_CLASS_VISIBILITY_PUBLIC = false;
+}
+
+PARSER_BEGIN(JsonParser)
+
+package com.eatthepath.json;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+class JsonParser {
+}
+
+PARSER_END(JsonParser)
+
+TOKEN_MGR_DECLS :
+{
+  private static final Map<String, Character> ESCAPED_CHARACTERS = new HashMap<>();
+
+  static {
+    ESCAPED_CHARACTERS.put("\\\"", '\"');
+    ESCAPED_CHARACTERS.put("\\\\", '\\');
+    ESCAPED_CHARACTERS.put("\\/", '/');
+    ESCAPED_CHARACTERS.put("\\b", '\b');
+    ESCAPED_CHARACTERS.put("\\f", '\f');
+    ESCAPED_CHARACTERS.put("\\n", '\n');
+    ESCAPED_CHARACTERS.put("\\r", '\r');
+    ESCAPED_CHARACTERS.put("\\t", '\t');
+  }
+}
+
+SKIP : {
+    " " | "\t" | "\r" | "\n"
+}
+
+SKIP : {
+    "\"" : WithinString
+}
+
+<WithinString> MORE : {
+    <ESCAPED_CONTROL_CHARACTER: "\\" ["\"", "\\", "/", "b", "f", "n", "r", "t"]> {
+        final char escapedCharacter = ESCAPED_CHARACTERS.get(image.substring(image.length() - lengthOfMatch));
+
+        image.setLength(image.length() - lengthOfMatch);
+        image.append(escapedCharacter);
+    } |
+
+    <#HEX_DIGIT: ["0"-"9", "a"-"f", "A"-"F"]> |
+    <ESCAPED_UNICODE_CHARACTER: "\\u" <HEX_DIGIT> <HEX_DIGIT> <HEX_DIGIT> <HEX_DIGIT>> {
+        final char unicodeCharacter = (char) Integer.parseInt(image.substring(image.length() - 4), 16);
+
+        image.setLength(image.length() - lengthOfMatch);
+        image.append(unicodeCharacter);
+    } |
+
+    <NON_CONTROL_CHARACTER: ~["\"", "\\", "\b", "\f", "\n", "\r", "\t"]>
+}
+
+<WithinString> TOKEN : {
+    <STRING: "\""> {
+        matchedToken.image = image.substring(0, image.length() - 1);
+    } : DEFAULT
+}
+
+TOKEN : {
+    <#DECIMAL_DIGIT: ["0"-"9"]> |
+    <INTEGER: ("-")? (<DECIMAL_DIGIT>)+> |
+    <FLOAT: ("-")? (<DECIMAL_DIGIT>)+ ("." (<DECIMAL_DIGIT>)+)? (["e", "E"] (["+", "-"])? (<DECIMAL_DIGIT>)+)?> |
+
+    <TRUE: "true"> |
+    <FALSE: "false"> |
+
+    <NULL: "null">
+}
+
+Map<String, Object> object():
+{
+    final Map<String, Object> map = new HashMap<>();
+
+    String key;
+    Object value;
+}
+{
+    "{" (key = string() ":" value = value() { map.put(key, value); }
+        ("," key = string() ":" value = value() { map.put(key, value); })*
+    )? "}" { return map; }
+}
+
+List<Object> list():
+{
+    final List<Object> list = new ArrayList<>();
+    Object element;
+}
+{
+    "[" (element = value() { list.add(element); }
+        ("," element = value() { list.add(element); })*
+    )? "]" { return list; }
+}
+
+Object value():
+{
+    final Object value;
+}
+{
+    (
+        value = string() |
+        value = number() |
+        value = object() |
+        value = list() |
+        <TRUE> { value = true; } |
+        <FALSE> { value = false; } |
+        <NULL> { value = null; }
+    ) { return value; }
+}
+
+String string():
+{
+    final Token token;
+}
+{
+    token = <STRING> { return token.image; }
+}
+
+Number number():
+{
+    final Number number;
+    final Token token;
+}
+{
+    (
+        token = <INTEGER> { number = Long.parseLong(token.image, 10); } |
+        token = <FLOAT> { number = Double.parseDouble(token.image); }
+    ) { return number; }
+}


Test patch:
diff --git a/pushy/src/test/java/com/eatthepath/json/JsonDeserializerTest.java b/pushy/src/test/java/com/eatthepath/json/JsonDeserializerTest.java
new file mode 100644
index 00000000..3241bdd6
--- /dev/null
+++ b/pushy/src/test/java/com/eatthepath/json/JsonDeserializerTest.java
@@ -0,0 +1,55 @@
+package com.eatthepath.json;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+class JsonDeserializerTest {
+
+    @ParameterizedTest
+    @MethodSource("argumentsForParseJsonObject")
+    void parseJsonObject(final String jsonString, final Map<String, Object> expectedObject) throws ParseException {
+        assertEquals(expectedObject, new JsonDeserializer().parseJsonObject(jsonString));
+    }
+
+    private static Stream<Arguments> argumentsForParseJsonObject() {
+        final Map<String, Object> multipleEntryMap = new HashMap<>();
+        multipleEntryMap.put("moose", 12L);
+        multipleEntryMap.put("porcupine", true);
+
+        return Stream.of(
+                arguments("{}", Collections.emptyMap()),
+                arguments("{\"moose\": 12}", Collections.singletonMap("moose", 12L)),
+                arguments("{\"moose\": 12, \"porcupine\": true}", multipleEntryMap),
+                arguments("{\"moose\": {\"porcupine\": true}}",
+                        Collections.singletonMap("moose", Collections.singletonMap("porcupine", true))));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"", "{", "false", "{{}"})
+    void parseJsonObjectBogusJsonString(final String jsonString) {
+        assertThrows(ParseException.class, () -> new JsonDeserializer().parseJsonObject(jsonString));
+    }
+
+    @Test
+    void parseJsonObjectReuse() throws ParseException {
+        final JsonDeserializer jsonDeserializer = new JsonDeserializer();
+
+        assertEquals(jsonDeserializer.parseJsonObject("{\"moose\": 12}"),
+                Collections.singletonMap("moose", 12L));
+
+        assertEquals(jsonDeserializer.parseJsonObject("{\"moose\": {\"porcupine\": true}}"),
+                Collections.singletonMap("moose", Collections.singletonMap("porcupine", true)));
+    }
+}
diff --git a/pushy/src/test/java/com/eatthepath/json/JsonParserTest.java b/pushy/src/test/java/com/eatthepath/json/JsonParserTest.java
new file mode 100644
index 00000000..22d9b796
--- /dev/null
+++ b/pushy/src/test/java/com/eatthepath/json/JsonParserTest.java
@@ -0,0 +1,97 @@
+package com.eatthepath.json;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.StringReader;
+import java.util.*;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+class JsonParserTest {
+
+    @ParameterizedTest
+    @MethodSource("argumentsForObject")
+    void object(final String jsonString, final Map<String, Object> expectedMap) throws ParseException {
+        assertEquals(expectedMap, new JsonParser(new StringReader(jsonString)).object());
+    }
+
+    private static Stream<Arguments> argumentsForObject() {
+        final Map<String, Object> multipleEntryMap = new HashMap<>();
+        multipleEntryMap.put("moose", 12L);
+        multipleEntryMap.put("porcupine", true);
+
+        return Stream.of(
+                arguments("{}", Collections.emptyMap()),
+                arguments("{\"moose\": 12}", Collections.singletonMap("moose", 12L)),
+                arguments("{\"moose\": 12, \"porcupine\": true}", multipleEntryMap),
+                arguments("{\"moose\": {\"porcupine\": true}}",
+                        Collections.singletonMap("moose", Collections.singletonMap("porcupine", true))));
+    }
+
+    @ParameterizedTest
+    @MethodSource("argumentsForList")
+    void list(final String jsonString, final List<Object> expectedList) throws ParseException {
+        assertEquals(expectedList, new JsonParser(new StringReader(jsonString)).list());
+    }
+
+    private static Stream<Arguments> argumentsForList() {
+        return Stream.of(
+                arguments("[]", Collections.emptyList()),
+                arguments("[true]", Collections.singletonList(true)),
+                arguments("[true, \"electrode\"]", Arrays.asList(true, "electrode")));
+    }
+
+    @ParameterizedTest
+    @MethodSource("argumentsForValue")
+    void value(final String jsonString, final Object expectedValue) throws ParseException {
+        assertEquals(expectedValue, new JsonParser(new StringReader(jsonString)).value());
+    }
+
+    private static Stream<Arguments> argumentsForValue() {
+        return Stream.of(
+                arguments("\"test\"", "test"),
+                arguments("17", 17L),
+                arguments("{\"test\": \"moose\"}", Collections.singletonMap("test", "moose")),
+                arguments("[7, false, null]", Arrays.asList(7L, false, null)),
+                arguments("true", true),
+                arguments("false", false),
+                arguments("null", null));
+    }
+
+    @ParameterizedTest
+    @MethodSource("argumentsForString")
+    void string(final String jsonString, final String expectedString) throws ParseException {
+        assertEquals(expectedString, new JsonParser(new StringReader(jsonString)).string());
+    }
+
+    private static Stream<Arguments> argumentsForString() {
+        return Stream.of(
+                arguments("\"\"", ""),
+                arguments("\"test\"", "test"),
+                arguments("\"\\t\"", "\t"),
+                arguments("\"Line\\nbreak\"", "Line\nbreak"),
+                arguments("\"\\u2713\"", "✓"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("argumentsForNumber")
+    void number(final String jsonString, final Number expectedNumber) throws ParseException {
+        assertEquals(expectedNumber, new JsonParser(new StringReader(jsonString)).number());
+    }
+
+    private static Stream<Arguments> argumentsForNumber() {
+        return Stream.of(
+                arguments("0", 0L),
+                arguments("1", 1L),
+                arguments("1234567890", 1234567890L),
+                arguments("-1234567890", -1234567890L),
+                arguments("1.23", 1.23),
+                arguments("-1.23", -1.23),
+                arguments("-2e4", -2e4),
+                arguments("7.2e-3", 7.2e-3));
+    }
+}
diff --git a/pushy/src/test/java/com/eatthepath/json/JsonSerializerTest.java b/pushy/src/test/java/com/eatthepath/json/JsonSerializerTest.java
new file mode 100644
index 00000000..9339ac2c
--- /dev/null
+++ b/pushy/src/test/java/com/eatthepath/json/JsonSerializerTest.java
@@ -0,0 +1,626 @@
+package com.eatthepath.json;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.IOException;
+import java.util.*;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+
+@SuppressWarnings("ZeroLengthArrayAllocation")
+public class JsonSerializerTest {
+
+    @Test
+    void jsonSerializer() {
+        assertThrows(InstantiationException.class, JsonSerializer::new);
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextMap")
+    void writeJsonTextMap(final Map<?, ?> map, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(map, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextMap")
+    void writeJsonTextAsStringMap(final Map<?, ?> map, final String expectedValue) {
+        assertEquals(expectedValue, JsonSerializer.writeJsonTextAsString(map));
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonTextMap() {
+        final SortedMap<String, String> multiEntryMap;
+        {
+            multiEntryMap = new TreeMap<>();
+            multiEntryMap.put("a", "b");
+            multiEntryMap.put("c", "d");
+        }
+
+        return Stream.of(
+                arguments(null, "null"),
+                arguments(Collections.emptyMap(), "{}"),
+                arguments(Collections.singletonMap("key", "value"), "{\"key\":\"value\"}"),
+                arguments(multiEntryMap, "{\"a\":\"b\",\"c\":\"d\"}"),
+                arguments(Collections.singletonMap("outerKey", Collections.singletonMap("innerKey", "value")),
+                        "{\"outerKey\":{\"innerKey\":\"value\"}}")
+        );
+    }
+
+    @Test
+    void writeJsonTextMapNullKey() {
+        final Map<?, ?> map = Collections.singletonMap(null, "test");
+
+        assertThrows(NullPointerException.class, () -> JsonSerializer.writeJsonValue(map, new StringBuilder()));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextCollection")
+    void writeJsonTextCollection(final Collection<?> collection, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(collection, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextCollection")
+    void writeJsonTextAsStringCollection(final Collection<?> collection, final String expectedValue) {
+        assertEquals(expectedValue, JsonSerializer.writeJsonTextAsString(collection));
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonTextCollection() {
+        return Stream.of(
+                arguments(null, "null"),
+                arguments(Collections.emptySet(), "[]"),
+                arguments(Collections.singletonList(true), "[true]"),
+                arguments(Arrays.asList(true, 4, "test"), "[true,4,\"test\"]"),
+                arguments(Arrays.asList(Collections.singletonList(1), Collections.singletonList(2)), "[[1],[2]]"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextByteArray")
+    void writeJsonTextByteArray(final byte[] array, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(array, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextByteArray")
+    void writeJsonTextAsStringByteArray(final byte[] array, final String expectedValue) {
+        assertEquals(expectedValue, JsonSerializer.writeJsonTextAsString(array));
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonTextByteArray() {
+        return Stream.of(
+                arguments(null, "null"),
+                arguments(new byte[0], "[]"),
+                arguments(new byte[] { 1 }, "[1]"),
+                arguments(new byte[] { 1, 2, 3 }, "[1,2,3]"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextShortArray")
+    void writeJsonTextShortArray(final short[] array, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(array, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextShortArray")
+    void writeJsonTextAsStringShortArray(final short[] array, final String expectedValue) {
+        assertEquals(expectedValue, JsonSerializer.writeJsonTextAsString(array));
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonTextShortArray() {
+        return Stream.of(
+                arguments(null, "null"),
+                arguments(new short[0], "[]"),
+                arguments(new short[] { 1 }, "[1]"),
+                arguments(new short[] { 1, 2, 3 }, "[1,2,3]"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextIntArray")
+    void writeJsonTextIntArray(final int[] array, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(array, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextIntArray")
+    void writeJsonTextAsStringIntArray(final int[] array, final String expectedValue) {
+        assertEquals(expectedValue, JsonSerializer.writeJsonTextAsString(array));
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonTextIntArray() {
+        return Stream.of(
+                arguments(null, "null"),
+                arguments(new int[0], "[]"),
+                arguments(new int[] { 1 }, "[1]"),
+                arguments(new int[] { 1, 2, 3 }, "[1,2,3]"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextLongArray")
+    void writeJsonTextLongArray(final long[] array, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(array, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextLongArray")
+    void writeJsonTextAsStringLongArray(final long[] array, final String expectedValue) {
+        assertEquals(expectedValue, JsonSerializer.writeJsonTextAsString(array));
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonTextLongArray() {
+        return Stream.of(
+                arguments(null, "null"),
+                arguments(new long[0], "[]"),
+                arguments(new long[] { 1L }, "[1]"),
+                arguments(new long[] { 1L, 2L, 3L }, "[1,2,3]"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextFloatArray")
+    void writeJsonTextFloatArray(final float[] array, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(array, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextFloatArray")
+    void writeJsonTextAsStringFloatArray(final float[] array, final String expectedValue) {
+        assertEquals(expectedValue, JsonSerializer.writeJsonTextAsString(array));
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonTextFloatArray() {
+        return Stream.of(
+                arguments(null, "null"),
+                arguments(new float[0], "[]"),
+                arguments(new float[] { 1f }, "[1.0]"),
+                arguments(new float[] { 1f, 2f, 3f }, "[1.0,2.0,3.0]"));
+
+        // TODO What about NaN, Infinity in lists?
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextDoubleArray")
+    void writeJsonTextDoubleArray(final double[] array, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(array, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextDoubleArray")
+    void writeJsonTextAsStringDoubleArray(final double[] array, final String expectedValue) {
+        assertEquals(expectedValue, JsonSerializer.writeJsonTextAsString(array));
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonTextDoubleArray() {
+        return Stream.of(
+                arguments(null, "null"),
+                arguments(new double[0], "[]"),
+                arguments(new double[] { 1d }, "[1.0]"),
+                arguments(new double[] { 1d, 2d, 3d }, "[1.0,2.0,3.0]"));
+
+        // TODO What about NaN, Infinity in lists?
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextBooleanArray")
+    void writeJsonTextBooleanArray(final boolean[] array, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(array, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextBooleanArray")
+    void writeJsonTextAsStringBooleanArray(final boolean[] array, final String expectedValue) {
+        assertEquals(expectedValue, JsonSerializer.writeJsonTextAsString(array));
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonTextBooleanArray() {
+        return Stream.of(
+                arguments(null, "null"),
+                arguments(new boolean[0], "[]"),
+                arguments(new boolean[] { true }, "[true]"),
+                arguments(new boolean[] { false, true }, "[false,true]"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextCharArray")
+    void writeJsonTextCharArray(final char[] array, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(array, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextCharArray")
+    void writeJsonTextAsStringCharArray(final char[] array, final String expectedValue) {
+        assertEquals(expectedValue, JsonSerializer.writeJsonTextAsString(array));
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonTextCharArray() {
+        return Stream.of(
+                arguments(null, "null"),
+                arguments(new char[0], "[]"),
+                arguments(new char[] { 'a' }, "[\"a\"]"),
+                arguments(new char[] { 'a', '\t', '…' }, "[\"a\",\"\\t\",\"…\"]"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextObjectArray")
+    void writeJsonTextObjectArray(final Object[] array, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(array, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonTextObjectArray")
+    void writeJsonTextAsStringObjectArray(final Object[] array, final String expectedValue) {
+        assertEquals(expectedValue, JsonSerializer.writeJsonTextAsString(array));
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonTextObjectArray() {
+        return Stream.of(
+                arguments(null, "null"),
+                arguments(new Object[0], "[]"),
+                arguments(new Object[] { Collections.emptyMap() }, "[{}]"),
+                arguments(new Object[] { 1, true, "three" }, "[1,true,\"three\"]"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonValueObject")
+    void writeJsonValueObject(final Object object, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(object, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonValueObject() {
+        return Stream.of(
+                arguments(null, "null"),
+                arguments(Optional.empty(), "null"),
+                arguments(Optional.of(false), "false"),
+                arguments(Collections.emptyMap(), "{}"),
+                arguments(Collections.emptySet(), "[]"),
+                arguments(new byte[] { 1, 2, 3 }, "[1,2,3]"),
+                arguments(new short[] { 1, 2, 3 }, "[1,2,3]"),
+                arguments(new int[] { 1, 2, 3 }, "[1,2,3]"),
+                arguments(new long[] { 1, 2, 3 }, "[1,2,3]"),
+                arguments(new float[] { 1, 2, 3 }, "[1.0,2.0,3.0]"),
+                arguments(new double[] { 1, 2, 3 }, "[1.0,2.0,3.0]"),
+                arguments(new boolean[] { true, false }, "[true,false]"),
+                arguments(new char[] { 'a', 'b', '\t' }, "[\"a\",\"b\",\"\\t\"]"),
+                arguments(new Object[] { null, Collections.emptyMap(), 4, "test" }, "[null,{},4,\"test\"]"),
+                arguments(4, "4"),
+                arguments(true, "true"),
+                arguments(new Object() {
+                    @Override
+                    public String toString() {
+                        return "Other object";
+                    }
+                }, "\"Other object\""));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonValueString")
+    void writeJsonValueString(final String string, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(string, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonValueString() {
+        return Stream.of(
+                arguments(null, "null"),
+                arguments("", "\"\""),
+                arguments("\r\n", "\"\\r\\n\""),
+                arguments("\ta", "\"\\ta\""),
+                arguments("🤘", "\"🤘\""),
+                arguments("String with no escaped characters", "\"String with no escaped characters\""),
+                arguments("String with \b\f\n\r\t lots/of \\ \"escaped characters\" \u0006 and still more text…",
+                        "\"String with \\b\\f\\n\\r\\t lots\\/of \\\\ \\\"escaped characters\\\" \\u0006 and still more text…\""));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonValueChar")
+    void writeJsonValueChar(final char c, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(c, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonValueChar() {
+        return Stream.of(
+                arguments('x', "\"x\""),
+                arguments('"', "\"\\\"\""),
+                arguments('\\', "\"\\\\\""),
+                arguments('/', "\"\\/\""),
+                arguments('\b', "\"\\b\""),
+                arguments('\f', "\"\\f\""),
+                arguments('\n', "\"\\n\""),
+                arguments('\r', "\"\\r\""),
+                arguments('\t', "\"\\t\""),
+                arguments('\u0006', "\"\\u0006\""),
+                arguments('√', "\"√\""));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonValueNumber")
+    void writeJsonValueNumber(final Number number, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(number, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonValueNumber() {
+        return Stream.of(
+                arguments(null, "null"),
+                arguments(1L, "1"),
+                arguments(2, "2"),
+                arguments((short) 3, "3"),
+                arguments((byte) 4, "4"),
+                arguments(5.5f, "5.5"),
+                arguments(6.6d, "6.6")
+        );
+    }
+
+    @Test
+    void writeJsonValueNumberIllegalValues() {
+        final StringBuilder stringBuilder = new StringBuilder();
+
+        assertThrows(IllegalArgumentException.class,
+                () -> JsonSerializer.writeJsonValue(Float.valueOf(Float.NaN), stringBuilder));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> JsonSerializer.writeJsonValue(Float.valueOf(Float.NEGATIVE_INFINITY), stringBuilder));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> JsonSerializer.writeJsonValue(Float.valueOf(Float.POSITIVE_INFINITY), stringBuilder));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> JsonSerializer.writeJsonValue(Double.valueOf(Double.NaN), stringBuilder));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> JsonSerializer.writeJsonValue(Double.valueOf(Double.NEGATIVE_INFINITY), stringBuilder));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> JsonSerializer.writeJsonValue(Double.valueOf(Double.POSITIVE_INFINITY), stringBuilder));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonValueByte")
+    void writeJsonValueByte(final byte b, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(b, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonValueByte() {
+        return Stream.of(
+                arguments((byte) 0, "0"),
+                arguments((byte) 1, "1"),
+                arguments((byte) -1, "-1"),
+                arguments(Byte.MAX_VALUE, Byte.toString(Byte.MAX_VALUE)),
+                arguments(Byte.MIN_VALUE, Byte.toString(Byte.MIN_VALUE)));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonValueShort")
+    void writeJsonValueShort(final short s, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(s, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonValueShort() {
+        return Stream.of(
+                arguments((short) 0, "0"),
+                arguments((short) 1L, "1"),
+                arguments((short) -1L, "-1"),
+                arguments(Short.MAX_VALUE, Short.toString(Short.MAX_VALUE)),
+                arguments(Short.MIN_VALUE, Short.toString(Short.MIN_VALUE)));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonValueInt")
+    void writeJsonValueInt(final int i, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(i, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonValueInt() {
+        return Stream.of(
+                arguments(0, "0"),
+                arguments(1, "1"),
+                arguments(-1, "-1"),
+                arguments(Integer.MAX_VALUE, Integer.toString(Integer.MAX_VALUE)),
+                arguments(Integer.MIN_VALUE, Integer.toString(Integer.MIN_VALUE)));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonValueLong")
+    void writeJsonValueLong(final long l, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(l, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonValueLong() {
+        return Stream.of(
+                arguments(0L, "0"),
+                arguments(1L, "1"),
+                arguments(-1L, "-1"),
+                arguments(Long.MAX_VALUE, Long.toString(Long.MAX_VALUE)),
+                arguments(Long.MIN_VALUE, Long.toString(Long.MIN_VALUE)));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonValueFloatBoxed")
+    void writeJsonValueFloatBoxed(final Float f, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(f, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonValueFloatBoxed() {
+        return Stream.of(
+                arguments(null, "null"),
+                arguments(0f, "0.0"),
+                arguments(1f, "1.0"),
+                arguments(-1f, "-1.0"),
+                arguments(1.2f, "1.2"),
+                arguments(Float.MAX_VALUE, Float.toString(Float.MAX_VALUE)),
+                arguments(Float.MIN_VALUE, Float.toString(Float.MIN_VALUE)));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonValueFloat")
+    void writeJsonValueFloat(final float f, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(f, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonValueFloat() {
+        return Stream.of(
+                arguments(0f, "0.0"),
+                arguments(1f, "1.0"),
+                arguments(-1f, "-1.0"),
+                arguments(1.2f, "1.2"),
+                arguments(Float.MAX_VALUE, Float.toString(Float.MAX_VALUE)),
+                arguments(Float.MIN_VALUE, Float.toString(Float.MIN_VALUE)));
+    }
+
+    @Test
+    void writeJsonValueFloatIllegalValues() {
+        final StringBuilder stringBuilder = new StringBuilder();
+
+        assertThrows(IllegalArgumentException.class,
+                () -> JsonSerializer.writeJsonValue(Float.NaN, stringBuilder));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> JsonSerializer.writeJsonValue(Float.NEGATIVE_INFINITY, stringBuilder));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> JsonSerializer.writeJsonValue(Float.POSITIVE_INFINITY, stringBuilder));
+    }
+
+    @Test
+    void writeJsonValueDoubleBoxedIllegalValues() {
+        final StringBuilder stringBuilder = new StringBuilder();
+
+        assertThrows(IllegalArgumentException.class,
+                () -> JsonSerializer.writeJsonValue(Double.valueOf(Double.NaN), stringBuilder));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> JsonSerializer.writeJsonValue(Double.valueOf(Double.NEGATIVE_INFINITY), stringBuilder));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> JsonSerializer.writeJsonValue(Double.valueOf(Double.POSITIVE_INFINITY), stringBuilder));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonValueDouble")
+    void writeJsonValueDouble(final double d, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(d, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonValueDouble() {
+        return Stream.of(
+                arguments(0d, "0.0"),
+                arguments(1d, "1.0"),
+                arguments(-1d, "-1.0"),
+                arguments(1.2d, "1.2"),
+                arguments(Double.MAX_VALUE, Double.toString(Double.MAX_VALUE)),
+                arguments(Double.MIN_VALUE, Double.toString(Double.MIN_VALUE)));
+    }
+
+    @Test
+    void writeJsonValueDoubleIllegalValues() {
+        final StringBuilder stringBuilder = new StringBuilder();
+
+        assertThrows(IllegalArgumentException.class,
+                () -> JsonSerializer.writeJsonValue(Double.NaN, stringBuilder));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> JsonSerializer.writeJsonValue(Double.NEGATIVE_INFINITY, stringBuilder));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> JsonSerializer.writeJsonValue(Double.POSITIVE_INFINITY, stringBuilder));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonValueBooleanBoxed")
+    void writeJsonValueBooleanBoxed(final Boolean b, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(b, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonValueBooleanBoxed() {
+        return Stream.of(
+                arguments(null, "null"),
+                arguments(Boolean.TRUE, "true"),
+                arguments(Boolean.FALSE, "false"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parametersForWriteJsonValueBoolean")
+    void writeJsonValueBoolean(final boolean b, final String expectedValue) throws IOException {
+        final StringBuilder stringBuilder = new StringBuilder();
+        JsonSerializer.writeJsonValue(b, stringBuilder);
+
+        assertEquals(expectedValue, stringBuilder.toString());
+    }
+
+    private static Stream<Arguments> parametersForWriteJsonValueBoolean() {
+        return Stream.of(
+                arguments(true, "true"),
+                arguments(false, "false"));
+    }
+}
diff --git a/pushy/src/test/java/com/eatthepath/pushy/apns/ErrorResponseTest.java b/pushy/src/test/java/com/eatthepath/pushy/apns/ErrorResponseTest.java
new file mode 100644
index 00000000..c0257538
--- /dev/null
+++ b/pushy/src/test/java/com/eatthepath/pushy/apns/ErrorResponseTest.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2020 Jon Chambers
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package com.eatthepath.pushy.apns;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class ErrorResponseTest {
+
+    @Test
+    void fromMap() {
+        {
+            final ErrorResponse emptyError = ErrorResponse.fromMap(Collections.emptyMap());
+
+            assertNull(emptyError.getReason());
+            assertNull(emptyError.getTimestamp());
+        }
+
+        {
+            final String reason = "Badness";
+
+            final ErrorResponse reasonOnlyError =
+                    ErrorResponse.fromMap(Collections.singletonMap("reason", reason));
+
+            assertEquals(reason, reasonOnlyError.getReason());
+            assertNull(reasonOnlyError.getTimestamp());
+        }
+
+        {
+            final String reason = "Badness";
+            final Instant timestamp = Instant.now();
+
+            final Map<String, Object> errorResponseMap = new HashMap<>();
+            errorResponseMap.put("reason", reason);
+            errorResponseMap.put("timestamp", timestamp.toEpochMilli());
+
+            final ErrorResponse errorResponse = ErrorResponse.fromMap(errorResponseMap);
+
+            assertEquals(reason, errorResponse.getReason());
+            assertEquals(timestamp, errorResponse.getTimestamp());
+        }
+
+        {
+            final Map<String, Object> wrongTypesErrorMap = new HashMap<>();
+            wrongTypesErrorMap.put("reason", 17);
+            wrongTypesErrorMap.put("timestamp", false);
+
+            final ErrorResponse errorResponse = ErrorResponse.fromMap(wrongTypesErrorMap);
+
+            assertNull(errorResponse.getReason());
+            assertNull(errorResponse.getTimestamp());
+        }
+    }
+}
diff --git a/pushy/src/test/java/com/eatthepath/pushy/apns/InstantAsTimeSinceEpochTypeAdapterTest.java b/pushy/src/test/java/com/eatthepath/pushy/apns/InstantAsTimeSinceEpochTypeAdapterTest.java
deleted file mode 100644
index f8c4a035..00000000
--- a/pushy/src/test/java/com/eatthepath/pushy/apns/InstantAsTimeSinceEpochTypeAdapterTest.java
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * Copyright (c) 2020 Jon Chambers
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- */
-
-package com.eatthepath.pushy.apns;
-
-import com.eatthepath.pushy.apns.util.InstantAsTimeSinceEpochTypeAdapter;
-import com.google.gson.JsonNull;
-import com.google.gson.JsonPrimitive;
-import org.junit.jupiter.api.Test;
-
-import java.time.Instant;
-import java.util.concurrent.TimeUnit;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-public class InstantAsTimeSinceEpochTypeAdapterTest {
-
-    @Test
-    void testInstantAsTimeSinceEpochTypeAdapterNullUnits() {
-        assertThrows(NullPointerException.class, () -> new InstantAsTimeSinceEpochTypeAdapter(null));
-    }
-
-    @Test
-    void testDeserialize() {
-        {
-            final InstantAsTimeSinceEpochTypeAdapter adapter = new InstantAsTimeSinceEpochTypeAdapter(TimeUnit.MILLISECONDS);
-            assertNull(adapter.deserialize(JsonNull.INSTANCE, Instant.class, null));
-        }
-
-        {
-            final InstantAsTimeSinceEpochTypeAdapter adapter = new InstantAsTimeSinceEpochTypeAdapter(TimeUnit.MILLISECONDS);
-
-            final long timestampInMilliseconds = Instant.now().toEpochMilli();
-            final Instant instantFromTimestamp = Instant.ofEpochMilli(timestampInMilliseconds);
-
-            assertEquals(instantFromTimestamp, adapter.deserialize(new JsonPrimitive(timestampInMilliseconds), Instant.class, null));
-        }
-
-        {
-            final InstantAsTimeSinceEpochTypeAdapter adapter = new InstantAsTimeSinceEpochTypeAdapter(TimeUnit.SECONDS);
-
-            final long timestampInSeconds = Instant.now().getEpochSecond();
-            final Instant instantFromTimestamp = Instant.ofEpochSecond(timestampInSeconds);
-
-            assertEquals(instantFromTimestamp, adapter.deserialize(new JsonPrimitive(timestampInSeconds), Instant.class, null));
-        }
-
-    }
-
-    @Test
-    void testSerialize() {
-        {
-            final InstantAsTimeSinceEpochTypeAdapter adapter = new InstantAsTimeSinceEpochTypeAdapter(TimeUnit.MILLISECONDS);
-            assertEquals(JsonNull.INSTANCE, adapter.serialize(null, Instant.class, null));
-        }
-
-        {
-            final InstantAsTimeSinceEpochTypeAdapter adapter = new InstantAsTimeSinceEpochTypeAdapter(TimeUnit.MILLISECONDS);
-
-            final long timestampInMilliseconds = Instant.now().toEpochMilli();
-            final Instant instantFromTimestamp = Instant.ofEpochMilli(timestampInMilliseconds);
-
-            assertEquals(new JsonPrimitive(timestampInMilliseconds), adapter.serialize(instantFromTimestamp, Instant.class, null));
-        }
-
-        {
-            final InstantAsTimeSinceEpochTypeAdapter adapter = new InstantAsTimeSinceEpochTypeAdapter(TimeUnit.SECONDS);
-
-            final long timestampInSeconds = Instant.now().getEpochSecond();
-            final Instant instantFromTimestamp = Instant.ofEpochSecond(timestampInSeconds);
-
-            assertEquals(new JsonPrimitive(timestampInSeconds), adapter.serialize(instantFromTimestamp, Instant.class, null));
-        }
-    }
-}
diff --git a/pushy/src/test/java/com/eatthepath/pushy/apns/auth/AuthenticationTokenTest.java b/pushy/src/test/java/com/eatthepath/pushy/apns/auth/AuthenticationTokenTest.java
index 328c5b5d..04fcc0bb 100644
--- a/pushy/src/test/java/com/eatthepath/pushy/apns/auth/AuthenticationTokenTest.java
+++ b/pushy/src/test/java/com/eatthepath/pushy/apns/auth/AuthenticationTokenTest.java
@@ -29,6 +29,9 @@ import java.security.KeyPair;
 import java.security.interfaces.ECPrivateKey;
 import java.security.interfaces.ECPublicKey;
 import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.regex.Pattern;
 
 import static org.junit.jupiter.api.Assertions.*;
@@ -51,17 +54,115 @@ public class AuthenticationTokenTest {
     }
 
     @Test
-    void testAuthenticationTokenFromSigningKey() throws Exception {
-        // We're happy here as long as nothing explodes
-        new AuthenticationToken(this.signingKey, Instant.now());
+    void testHeaderFromMap() {
+        assertThrows(IllegalArgumentException.class, () ->
+                AuthenticationToken.AuthenticationTokenHeader.fromMap(Collections.emptyMap()));
+
+        assertThrows(IllegalArgumentException.class, () ->
+                AuthenticationToken.AuthenticationTokenHeader.fromMap(
+                        Collections.singletonMap("kid", false)));
+
+        final String keyId = "test-key-id";
+
+        final Map<String, Object> headerMap = new HashMap<>();
+        headerMap.put("alg", "ES256");
+        headerMap.put("typ", "JWT");
+        headerMap.put("kid", keyId);
+
+        final AuthenticationToken.AuthenticationTokenHeader authenticationTokenHeader =
+                AuthenticationToken.AuthenticationTokenHeader.fromMap(headerMap);
+
+        assertEquals(keyId, authenticationTokenHeader.getKeyId());
+    }
+
+    @Test
+    void testHeadersToMap() {
+        final String keyId = "test-key";
+
+        final Map<String, Object> headerMap = new HashMap<>();
+        headerMap.put("alg", "ES256");
+        headerMap.put("typ", "JWT");
+        headerMap.put("kid", keyId);
+
+        assertEquals(headerMap, new AuthenticationToken.AuthenticationTokenHeader(keyId).toMap());
+    }
+
+    @Test
+    void testClaimsFromMap() {
+        assertThrows(IllegalArgumentException.class,
+                () -> AuthenticationToken.AuthenticationTokenClaims.fromMap(
+                        Collections.emptyMap()));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> AuthenticationToken.AuthenticationTokenClaims.fromMap(
+                        Collections.singletonMap("iss", "team-id")));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> AuthenticationToken.AuthenticationTokenClaims.fromMap(
+                        Collections.singletonMap("iat", Instant.now().getEpochSecond())));
+
+        {
+            final Map<String, Object> badIssuerMap = new HashMap<>();
+            badIssuerMap.put("iss", false);
+            badIssuerMap.put("iat", Instant.now().getEpochSecond());
+
+            assertThrows(IllegalArgumentException.class,
+                    () -> AuthenticationToken.AuthenticationTokenClaims.fromMap(
+                            badIssuerMap));
+        }
+
+        {
+            final Map<String, Object> badTimestampMap = new HashMap<>();
+            badTimestampMap.put("iss", "team-id");
+            badTimestampMap.put("iat", "soon");
+
+            assertThrows(IllegalArgumentException.class,
+                    () -> AuthenticationToken.AuthenticationTokenClaims.fromMap(
+                            badTimestampMap));
+        }
+
+        {
+            final String teamId = "team-id";
+
+            // Hack: make sure we don't have a millisecond component
+            final Instant timestamp = Instant.ofEpochSecond(Instant.now().getEpochSecond());
+
+            final Map<String, Object> claimsMap = new HashMap<>();
+            claimsMap.put("iss", teamId);
+            claimsMap.put("iat", timestamp.getEpochSecond());
+
+            final AuthenticationToken.AuthenticationTokenClaims claims =
+                    AuthenticationToken.AuthenticationTokenClaims.fromMap(claimsMap);
+
+            assertEquals(teamId, claims.getIssuer());
+            assertEquals(timestamp, claims.getIssuedAt());
+        }
+    }
+
+    @Test
+    void testClaimsToMap() {
+        final String teamId = "team-id";
+
+        // Hack: make sure we don't have a millisecond component
+        final Instant timestamp = Instant.ofEpochSecond(Instant.now().getEpochSecond());
+
+        final Map<String, Object> claimsMap = new HashMap<>();
+        claimsMap.put("iss", teamId);
+        claimsMap.put("iat", timestamp.getEpochSecond());
+
+        assertEquals(claimsMap, new AuthenticationToken.AuthenticationTokenClaims(teamId, timestamp).toMap());
+    }
+
+    @Test
+    void testAuthenticationTokenFromSigningKey() {
+        assertDoesNotThrow(() -> new AuthenticationToken(this.signingKey, Instant.now()));
     }
 
     @Test
     void testAuthenticationTokenFromString() throws Exception {
         final String base64EncodedToken = new AuthenticationToken(this.signingKey, Instant.now()).toString();
 
-        // We're happy here as long as nothing explodes
-        new AuthenticationToken(base64EncodedToken);
+        assertDoesNotThrow(() -> new AuthenticationToken(base64EncodedToken));
     }
 
     @Test
diff --git a/pushy/src/test/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilderTest.java b/pushy/src/test/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilderTest.java
index 5fd03415..8e1e56ea 100644
--- a/pushy/src/test/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilderTest.java
+++ b/pushy/src/test/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilderTest.java
@@ -22,16 +22,14 @@
 
 package com.eatthepath.pushy.apns.util;
 
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.gson.reflect.TypeToken;
+import com.eatthepath.json.JsonDeserializer;
+import com.eatthepath.json.ParseException;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 
-import java.lang.reflect.Type;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -44,9 +42,6 @@ public class ApnsPayloadBuilderTest {
 
     private ApnsPayloadBuilder builder;
 
-    private static final Gson GSON = new GsonBuilder().serializeNulls().disableHtmlEscaping().create();
-    private static final Type MAP_OF_STRING_TO_OBJECT = new TypeToken<Map<String, Object>>(){}.getType();
-
     @BeforeEach
     public void setUp() {
         this.builder = new ApnsPayloadBuilder();
@@ -503,13 +498,13 @@ public class ApnsPayloadBuilderTest {
     }
 
     @Test
-    void testAddCustomProperty() {
+    void testAddCustomProperty() throws ParseException {
         final String customKey = "string";
         final String customValue = "Hello";
 
         this.builder.addCustomProperty(customKey, customValue);
 
-        final Map<String, Object> payload = GSON.fromJson(this.builder.build(), MAP_OF_STRING_TO_OBJECT);
+        final Map<String, Object> payload = new JsonDeserializer().parseJsonObject(this.builder.build());
 
         assertEquals(customValue, payload.get(customKey));
     }
@@ -588,7 +583,14 @@ public class ApnsPayloadBuilderTest {
 
     @SuppressWarnings("unchecked")
     private Map<String, Object> extractApsObjectFromPayloadString(final String payloadString) {
-        final Map<String, Object> payload = GSON.fromJson(payloadString, MAP_OF_STRING_TO_OBJECT);
+        final Map<String, Object> payload;
+
+        try {
+            payload = new JsonDeserializer().parseJsonObject(payloadString);
+        } catch (final ParseException e) {
+            throw new IllegalArgumentException("Failed to parse JSON", e);
+        }
+
         return (Map<String, Object>) payload.get("aps");
     }
 }