clazz) {
+ Constructor> constructor = Arrays.stream(clazz.getDeclaredConstructors())
+ .filter(cons -> cons.getParameterCount() == 0)
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("No default constructor found for " + clazz.getName()));
+
+ try {
+ constructor.setAccessible(true);
+ return (T) constructor.newInstance();
+ } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
+ throw new IllegalArgumentException("Failed to create new instance of " + clazz.getName(), e);
+ }
+ }
+
+ public static ParameterizedTypeHelper getParameterType(Parameter parameter) {
+ return getParameterType(parameter.getParameterizedType());
+ }
+
+ public static ParameterizedTypeHelper getParameterType(Field field) {
+ return getParameterType(field.getGenericType());
+ }
+
+ /**
+ * Creates an instance of ParameterizedTypeHelper from the given type.
+ *
+ * @param type the type to create the instance from
+ * @return the instance of ParameterizedTypeHelper or null if the type is not a ParameterizedType
+ * @throws IllegalStateException if the type is not a Class or a ParameterizedType
+ */
+ public static ParameterizedTypeHelper getParameterType(Type type) {
+ if (!(type instanceof ParameterizedType)) {
+ return null;
+ }
+
+ ParameterizedType parameterizedType = (ParameterizedType) type;
+
+ return new ParameterizedTypeHelper((Class>) parameterizedType.getRawType(),
+ parameterizedType.getActualTypeArguments());
+ }
+
+ /**
+ * Returns the component type of the given type.
+ *
+ * @param type the type to get the component type from
+ * @return the component type of the given type or null if the type is not an array
+ */
+ public static Class> getArrayType(Type type) {
+ if (type instanceof GenericArrayType) {
+ return (Class>) ((GenericArrayType) type).getGenericComponentType();
+ }
+
+ if (type instanceof Class>) {
+ return ((Class>) type).getComponentType();
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the component type of the given type.
+ *
+ * @param type the type to get the component type from
+ * @return the component type of the given type or null if the type is not an array
+ */
+ public static boolean isEnum(Type type) {
+ if (type instanceof Class>) {
+ return ((Class>) type).isEnum();
+ }
+ return false;
+ }
+
+ /**
+ * Returns a map of the writable fields of the given class,
+ * where the key is the name of the field or his alias and the value is the field itself.
+ *
+ * @param clazz the class to get the fields from
+ * @return a map of the fields of the given class
+ */
+ public static Map getFields(Class> clazz) {
+ Map methodMap = Arrays.stream(clazz.getDeclaredMethods())
+ .collect(Collectors.toMap(Method::getName, m -> m));
+
+ return Arrays.stream(clazz.getDeclaredFields())
+ .filter(ReflectionUtils::isWritable)
+ .collect(Collectors.toMap(field -> getFieldName(field, methodMap), f -> f));
+ }
+
+ /**
+ * Here we seek for the alias key in the accessor method, if it exists.
+ */
+ private static String getFieldName(Field field, Map methodMap) {
+ //base name of the field
+ String baseName = field.getName().substring(0, 1).toUpperCase() + field.getName().substring(1);
+ String alias = findAlias(methodMap, baseName, field.getType());
+
+ return Objects.requireNonNullElse(alias, field.getName());
+ }
+
+ /**
+ * Here we seek for the alias key in the accessor method, if it exists.
+ */
+ private static String getMutatorName(Method method, Map methodMap) {
+ //base name of the method
+ String baseName = method.getName().substring(3);
+ //seek for the alias key in the accessor method, if it exists
+ String alias = findAlias(methodMap, baseName, method.getParameterTypes()[0]);
+ if (alias != null) {
+ return alias;
+ }
+
+ return method.getName().substring(3, 4).toLowerCase()
+ + method.getName().substring(4);
+ }
+
+ /**
+ * Here we seek for the alias key in the accessor method, if it exists.
+ *
+ * @param methodMap current method map
+ * @param baseName base name of the method
+ * @param type type of the method
+ * @return alias key in the accessor method, if it exists
+ */
+ private static String findAlias(Map methodMap, String baseName, Class> type) {
+ Method accessorMethod;
+
+ if (type == boolean.class) {
+ accessorMethod = methodMap.get("is" + baseName);
+ } else {
+ accessorMethod = methodMap.get("get" + baseName);
+ }
+
+ if (accessorMethod != null) {
+ JsonAlias jsonAlias = accessorMethod.getAnnotation(JsonAlias.class);
+
+ if (jsonAlias != null) {
+ return jsonAlias.value();
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns a map of the mutator methods of the given class,
+ * where the key is the name of the mutator or his alias and the value is the method itself.
+ *
+ * @param clazz the class to get the mutators from
+ * @return a map of the mutators of the given class
+ */
+ public static Map getMutators(Class> clazz) {
+ Map methodMap = Arrays.stream(clazz.getDeclaredMethods())
+ .collect(Collectors.toMap(Method::getName, m -> m));
+
+ Map resultMap = new HashMap<>();
+
+ for (Method method : methodMap.values()) {
+ if (!isMutator(method)) continue;
+
+ String mutatorName = getMutatorName(method, methodMap);
+
+ resultMap.put(mutatorName, method);
+ }
+
+ return resultMap;
+ }
+
+
+ /**
+ * Checks if the given method is a mutator.
+ *
+ * @param method the method to check
+ * @return true if the method is a mutator, false otherwise
+ */
+ private static boolean isMutator(Method method) {
+ return method.getName().startsWith("set") && method.getParameterCount() == 1;
+ }
+
+ /**
+ * Checks if the given field is writable.
+ *
+ * @param field the field to check
+ * @return true if the field is writable, false otherwise
+ */
+ private static boolean isWritable(Field field) {
+ if (Modifier.isStatic(field.getModifiers())) {
+ return false;
+ }
+ return !Modifier.isFinal(field.getModifiers());
+ }
+
+}
diff --git a/src/main/java/net/jonathangiles/tools/teenyhttpd/json/AccessorInvoker.java b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/AccessorInvoker.java
new file mode 100644
index 0000000..3f9b6ed
--- /dev/null
+++ b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/AccessorInvoker.java
@@ -0,0 +1,45 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.util.function.Function;
+import java.util.logging.Logger;
+
+/**
+ * A class that wraps a method and a function that can be used to invoke the method. This is used to invoke methods on
+ * objects that are annotated with {@link JsonIgnore}, {@link JsonIncludeNonNull}, or {@link JsonRaw}.
+ */
+final class AccessorInvoker {
+ private final Method method;
+ private final Function function;
+
+ public AccessorInvoker(Method method, Function function) {
+ this.method = method;
+ this.function = function;
+ }
+
+ /**
+ * Invokes the method on the given instance, returning the result.
+ * @param instance the instance to invoke the method on
+ * @return the result of invoking the method on the given instance (or null if an error occurred)
+ */
+ public Object invoke(Object instance) {
+ try {
+ return function.apply(instance);
+ } catch (Exception ex) {
+ Logger.getLogger(AccessorInvoker.class.getName())
+ .severe("Error invoking method: " + method.getName());
+ }
+
+ return null;
+ }
+
+ public boolean isAnnotationPresent(Class extends Annotation> annotation) {
+ return method.isAnnotationPresent(annotation);
+ }
+
+ public T getAnnotation(Class annotation) {
+ return method.getAnnotation(annotation);
+ }
+}
diff --git a/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonAlias.java b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonAlias.java
new file mode 100644
index 0000000..af65055
--- /dev/null
+++ b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonAlias.java
@@ -0,0 +1,15 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An annotation to specify an alias for a field when serializing or deserializing JSON.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface JsonAlias {
+ String value();
+}
diff --git a/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonDecoder.java b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonDecoder.java
new file mode 100644
index 0000000..2960992
--- /dev/null
+++ b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonDecoder.java
@@ -0,0 +1,238 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A simple JSON decoder that can decode a JSON string into a Map or List of Maps.
+ */
+final class JsonDecoder {
+
+ private final Buffer buffer;
+
+ public JsonDecoder(String json) {
+ this.buffer = new Buffer(json);
+ }
+
+ /**
+ * Read the JSON string and return the parsed object.
+ *
+ * @return the parsed object which could be null.
+ */
+ Object read() {
+ char current = buffer.current();
+
+ if (current == '{') {
+ return readObject();
+ }
+
+ if (current == '"') {
+ return readString();
+ }
+
+ if (current == '[') {
+ return readArray();
+ }
+
+ if (Character.isDigit(current) ||
+ current == '-' ||
+ current == '+' ||
+ current == '.') {
+ return readNumber();
+ }
+
+ if (current == 'n' || current == 'N') {
+ buffer.next(4);
+ return null;
+ }
+
+ if (current == 'f' || current == 'F' ||
+ current == 't' || current == 'T') {
+ return readBoolean(current);
+ }
+
+ if (shouldSkip(current)) {
+ buffer.advanceSpaces();
+ return read();
+ }
+
+ return null;
+ }
+
+ private static boolean shouldSkip(char current) {
+ return current == ' ' || current == '\n' || current == '\t' || current == '\r';
+ }
+
+ private Object readBoolean(char current) {
+ if (current == 't' || current == 'T') {
+ return Boolean.parseBoolean(buffer.next(4));
+ }
+
+ return Boolean.parseBoolean(buffer.next(5));
+ }
+
+ private String readNumber() {
+ StringBuilder sb = new StringBuilder();
+
+ if (buffer.current() != '+') {
+ sb.append(buffer.current());
+ }
+
+ char temp;
+
+ while (buffer.next()) {
+ temp = buffer.current();
+
+ if ((temp == '}' || temp == ']') || temp == ',') {
+ break;
+ } else {
+ sb.append(temp);
+ }
+ }
+
+ buffer.back();
+
+ return sb.toString();
+ }
+
+ private List readArray() {
+ List result = new LinkedList<>();
+
+ boolean hasNext = true;
+ while (hasNext) {
+ buffer.next();
+ if (buffer.current() == ']') {
+ return result;
+ }
+
+ result.add(read());
+
+ if (buffer.current() == ']') {
+ hasNext = false;
+ }
+
+ buffer.next();
+
+ if (buffer.current() == ']') {
+ hasNext = false;
+ }
+ }
+
+ return result;
+ }
+
+ private String readString() {
+ StringBuilder sb = new StringBuilder();
+ buffer.advanceSpaces();
+
+ if (buffer.current() == '"') {
+ while (buffer.next()) {
+ char temp = buffer.current();
+
+ if ('"' == temp) {
+ if ('\\' != buffer.readBefore()) {
+ break;
+ }
+ }
+ sb.append(temp);
+ }
+ } else {
+ return "";
+ }
+
+ return sb.toString();
+ }
+
+ private Map readObject() {
+ Map result = new HashMap<>();
+
+ boolean hasNext = true;
+
+ while (hasNext) {
+ buffer.next();
+
+ if (buffer.current() == '}') {
+ return result;
+ }
+
+ //readString requires the buffer to be at the start of the string
+ if (buffer.last() == '"') {
+ buffer.back();
+ }
+
+ String key = readString();
+
+ while (buffer.next()) {
+ char temp = buffer.current();
+ if (temp == ':') {
+ buffer.next();
+ buffer.advanceSpaces();
+ break;
+ }
+ }
+
+ result.put(key, read());
+ buffer.next();
+
+ if (buffer.current() == '}') {
+ hasNext = false;
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * This class tracks the current index of the JSON string and provides methods to read the string.
+ */
+ private static class Buffer {
+ private final String json;
+ private int index;
+
+ Buffer(String json) {
+ this.json = json;
+ }
+
+ char current() {
+ return json.charAt(index);
+ }
+
+ char last() {
+ return json.charAt(index - 1);
+ }
+
+ void back() {
+ index--;
+ }
+
+ char readBefore() {
+ return json.charAt(index - 1);
+ }
+
+ boolean next() {
+ if (index + 1 >= json.length()) {
+ return false;
+ } else {
+ index++;
+ return true;
+ }
+ }
+
+ void advanceSpaces() {
+ while (shouldSkip(json.charAt(index))) {
+ index++;
+ }
+ }
+
+ String next(int i) {
+ String value = json.substring(index, index + i);
+ index += i;
+
+ return value;
+ }
+
+ }
+
+}
diff --git a/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonDeserialize.java b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonDeserialize.java
new file mode 100644
index 0000000..e99878c
--- /dev/null
+++ b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonDeserialize.java
@@ -0,0 +1,18 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation to specify how to deserialize a JSON string into a Java object.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface JsonDeserialize {
+
+ Class> contentAs() default Object.class;
+
+ Class> as() default Object.class;
+}
diff --git a/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonEncoder.java b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonEncoder.java
new file mode 100644
index 0000000..38b5834
--- /dev/null
+++ b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonEncoder.java
@@ -0,0 +1,454 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import java.lang.invoke.*;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Function;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+
+/**
+ * A simple JSON encoder that can serialize objects to a JSON string.
+ */
+final class JsonEncoder {
+
+ private final Map, Mapper> cache = new ConcurrentHashMap<>();
+ private final Map, ValueSerializer>> serializers = new ConcurrentHashMap<>();
+ private final SimpleDateFormat dateFormatter = new SimpleDateFormat("dd-MM-yyyy HH:mm:ss");
+
+ SimpleDateFormat getDateFormatter() {
+ return dateFormatter;
+ }
+
+ /**
+ * Registers a serializer for a specific class.
+ *
+ * @param clazz the class to register
+ */
+ public void registerSerializer(Class clazz, ValueSerializer serializer) {
+ serializers.put(clazz, serializer);
+ }
+
+ /**
+ * Writes an object to a JSON string.
+ *
+ * @param value the object to be written
+ * @return the JSON string
+ */
+ public String writeValueAsString(Object value) {
+ if (value == null) return null;
+ if (value instanceof String) return (String) value;
+
+ if (value instanceof Collection>) {
+ Collection> collection = (Collection>) value;
+ return "[" + collection.stream()
+ .map(this::serialize)
+ .collect(Collectors.joining(", ")) + "]";
+ }
+
+ return serialize(value);
+
+ }
+
+
+ /**
+ * Serializes an object to a JSON string.
+ *
+ * @param object the object to be serialized
+ * @return the JSON string
+ */
+ private String serialize(Object object) {
+ if (object == null) return "null";
+
+ Mapper cachedMapper = cache.get(object.getClass());
+
+ if (cachedMapper != null) {
+ return cachedMapper.serialize(object, this);
+ }
+
+ if (object instanceof Map) {
+ Map, ?> map = (Map, ?>) object;
+ return "{" + map.entrySet()
+ .stream()
+ .map(entry -> "\"" + entry.getKey() + "\":" + serialize(entry.getValue()))
+ .collect(Collectors.joining(",")) + "}";
+ }
+
+ if (object instanceof Collection) {
+ Collection> list = (Collection>) object;
+ return "[" + list.stream()
+ .map(this::serialize)
+ .collect(Collectors.joining(",")) + "]";
+ }
+
+ if (object.getClass().isArray()) {
+ return serializeArray(object);
+ }
+
+ final Class> clazz = object.getClass();
+
+ if (clazz.getName().startsWith("java.lang")) {
+ return writeValue(object);
+ }
+
+ Method[] methods = clazz.getDeclaredMethods();
+
+ Mapper mapper = new Mapper(clazz);
+ cache.put(clazz, mapper);
+
+ for (Method method : methods) {
+ try {
+ if (method.getName().equals("hashCode")) continue;
+ if (method.getName().equals("toString")) continue;
+
+ mapper.put(method);
+ } catch (Throwable e) {
+ Logger.getLogger(JsonEncoder.class.getName())
+ .log(Level.SEVERE, "Error serializing object: " + object.getClass().getName(), e);
+ }
+ }
+
+ return mapper.serialize(object, this);
+ }
+
+ /**
+ * Serializes an array to a JSON string.
+ *
+ * This method handles both arrays of objects and arrays of primitives.
+ * For arrays of objects, it serializes each object in the array and joins them with a comma.
+ * For arrays of primitives, it converts the array to a string representation.
+ *
+ * @param object the array to be serialized, which can be an array of objects or an array of primitives
+ * @return the JSON string representation of the array
+ * @throws IllegalArgumentException if the array is of a type that isn't handled
+ */
+ private String serializeArray(Object object) {
+ if (object instanceof Object[]) {
+ Object[] array = (Object[]) object;
+ return "[" + Arrays.stream(array)
+ .map(this::serialize)
+ .collect(Collectors.joining(", ")) + "]";
+ } else if (object instanceof int[]) {
+ int[] array = (int[]) object;
+ return Arrays.toString(array).replace(" ", "");
+ } else if (object instanceof double[]) {
+ double[] array = (double[]) object;
+ return Arrays.toString(array).replace(" ", "");
+ } else if (object instanceof long[]) {
+ long[] array = (long[]) object;
+ return Arrays.toString(array).replace(" ", "");
+ } else if (object instanceof char[]) {
+ char[] array = (char[]) object;
+ return Arrays.toString(array).replace(" ", "");
+ } else if (object instanceof float[]) {
+ float[] array = (float[]) object;
+ return Arrays.toString(array).replace(" ", "");
+ } else if (object instanceof boolean[]) {
+ boolean[] array = (boolean[]) object;
+ return Arrays.toString(array).replace(" ", "");
+ } else if (object instanceof byte[]) {
+ byte[] array = (byte[]) object;
+ return Arrays.toString(array).replace(" ", "");
+ } else if (object instanceof short[]) {
+ short[] array = (short[]) object;
+ return Arrays.toString(array).replace(" ", "");
+ } else {
+ throw new IllegalArgumentException("Unknown array type: " + object.getClass().getName());
+ }
+ }
+
+ /**
+ * Writes a field to a JSON string.
+ *
+ * @param name the name of the field
+ * @param value the value of the field
+ * @param includeNonNull whether to include null values or not
+ * @return the JSON representation of the field
+ */
+ private String writeField(String name, Object value, boolean includeNonNull, AccessorInvoker invoker) {
+ if (includeNonNull && value == null) return null;
+
+ if (invoker.isAnnotationPresent(JsonRaw.class) && value instanceof String) {
+ if (invoker.getAnnotation(JsonRaw.class).includeKey()) {
+ return "\"" + name + "\":" + value;
+ }
+ return (String) value;
+ }
+
+ if (invoker.isAnnotationPresent(JsonIncludeNonNull.class) && value == null) return null;
+
+ StringBuilder sb = new StringBuilder();
+
+ try {
+ sb.append("\"").append(name).append("\":");
+
+ if (value instanceof Map) {
+ sb.append("{");
+
+ String map = ((Map, ?>) value)
+ .entrySet()
+ .stream()
+ .map(entry -> "\"" + entry.getKey() + "\":" + serialize(entry.getValue()))
+ .collect(Collectors.joining(", "));
+
+ return sb.append(map)
+ .append("}")
+ .toString();
+ }
+
+ if (value instanceof Collection) {
+ sb.append("[");
+
+ String collection = ((Collection>) value)
+ .stream()
+ .map(this::serialize)
+ .collect(Collectors.joining(","));
+
+ return sb.append(collection)
+ .append("]")
+ .toString();
+ }
+
+ sb.append(writeValue(value));
+ } catch (Exception e) {
+ Logger.getLogger(JsonEncoder.class.getName())
+ .log(Level.SEVERE, null, e);
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Gets the field name of a method.
+ * If the method is annotated with {@link JsonAlias}, the value of the annotation is returned.
+ *
+ * @param method the method to get the field name from
+ * @return the field name
+ */
+ private static String getFieldName(Method method) {
+ if (method.isAnnotationPresent(JsonAlias.class)) {
+ String alias = method.getAnnotation(JsonAlias.class).value();
+
+ if (!alias.isEmpty()) {
+ return alias;
+ }
+ }
+
+ String name = method.getName();
+
+ if (name.startsWith("get") || name.startsWith("set")) {
+ name = name.substring(3);
+ }
+
+ if (name.startsWith("is")) {
+ name = name.substring(2);
+ }
+
+ return name.substring(0, 1).toLowerCase() + name.substring(1);
+ }
+
+ /**
+ * Writes the value as a valid JSON value.
+ * @param value the value to be written
+ * @return the JSON representation of the value
+ */
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private String writeValue(Object value) {
+ if (value == null) return "null";
+
+ ValueSerializer serializer = serializers.get(value.getClass());
+
+ if (serializer != null) {
+ return serializer.serialize(value);
+ }
+
+ if (value instanceof String) {
+ return "\"" + escapeJsonString((String) value) + "\"";
+ }
+
+ if (value instanceof Number) {
+ return value.toString();
+ }
+
+ if (value instanceof Boolean) {
+ return value.toString();
+ }
+
+ if (value instanceof Date) {
+ return "\"" + dateFormatter.format(value) + "\"";
+ }
+
+ if (value instanceof Character) {
+ return "\"" + value + "\"";
+ }
+
+ if (value instanceof LocalDate) {
+ return "\"" + value + "\"";
+ }
+
+ if (value instanceof LocalDateTime) {
+ return "\"" + value + "\"";
+ }
+
+ if (value instanceof LocalTime) {
+ return "\"" + value + "\"";
+ }
+
+ if (value.getClass().isEnum()) {
+ return "\"" + value + "\"";
+ }
+
+ if (!value.getClass().getName().startsWith("java.lang") && !value.getClass().getName().startsWith("java.time")) {
+ return serialize(value);
+ }
+
+ System.err.println("Unknown type: " + value.getClass().getName());
+
+ return null;
+ }
+
+ /**
+ * Escapes a string to be a valid JSON string.
+ *
+ * @param s the string to be escaped
+ * @return the escaped string
+ */
+ private String escapeJsonString(String s) {
+ if (s == null) return null;
+
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < s.length(); i++) {
+ char ch = s.charAt(i);
+ switch (ch) {
+ case '"':
+ case '\\':
+ sb.append('\\');
+ sb.append(ch);
+ break;
+ case '\b':
+ sb.append("\\b");
+ break;
+ case '\f':
+ sb.append("\\f");
+ break;
+ case '\n':
+ sb.append("\\n");
+ break;
+ case '\r':
+ sb.append("\\r");
+ break;
+ case '\t':
+ sb.append("\\t");
+ break;
+ default:
+ if (ch <= 0x1F) {
+ String ss = Integer.toHexString(ch);
+ sb.append("\\u");
+ for (int k = 0; k < 4 - ss.length(); k++) {
+ sb.append('0');
+ }
+ sb.append(ss.toUpperCase());
+ } else {
+ sb.append(ch);
+ }
+ }
+ }
+ return sb.toString();
+ }
+
+ /**
+ * This class holds all lambdas to call the getter methods of a class.
+ * It also holds the target class and a flag to include or not null values.
+ * It is used to serialize an object to a JSON string.
+ */
+ private static class Mapper extends ConcurrentHashMap {
+ private final Class> target;
+ private final boolean includeNonNull;
+
+ public Mapper(Class> target) {
+ this.target = target;
+ includeNonNull = target.isAnnotationPresent(JsonIncludeNonNull.class);
+ }
+
+ private void put(Method method) throws Throwable {
+ if (method.isAnnotationPresent(JsonIgnore.class)) return;
+ if (Modifier.isStatic(method.getModifiers())) return;
+ if (method.getParameterCount() > 0) return;
+
+ String fieldName = getFieldName(method);
+ put(fieldName, createFunction(target, method));
+ }
+
+ /**
+ * Serializes an object to a JSON string.
+ * @param object the object to be serialized
+ * @param encoder the encoder to be used
+ * @return the JSON string
+ */
+ private String serialize(Object object, JsonEncoder encoder) {
+ List properties = new LinkedList<>();
+
+ for (Map.Entry entry : entrySet()) {
+ Object value = entry.getValue().invoke(object);
+ if (value == null && !includeNonNull) continue;
+ String field = encoder.writeField(entry.getKey(), value, includeNonNull, entry.getValue());
+ if (field == null) continue;
+
+ properties.add(field);
+ }
+
+ return "{" + String.join(",", properties) + "}";
+ }
+ }
+
+ /**
+ * Creates a function to call the getter method of a class.
+ *
+ * @param targetClass the class to create the function for
+ * @param method the method to call
+ * @return the function to call the method
+ * @throws Throwable if an error occurs while creating the function
+ */
+ private static AccessorInvoker createFunction(Class> targetClass, Method method) throws Throwable {
+ try {
+ MethodHandles.Lookup lookup = getLookup(targetClass);
+ MethodHandle virtualMethodHandle = lookup.findVirtual(targetClass, method.getName(), MethodType.methodType(method.getReturnType()));
+ CallSite site = LambdaMetafactory.metafactory(lookup,
+ "apply",
+ MethodType.methodType(Function.class),
+ MethodType.methodType(Object.class, Object.class),
+ virtualMethodHandle,
+ MethodType.methodType(method.getReturnType(), targetClass));
+ @SuppressWarnings("unchecked")
+ Function getterFunction = (Function) site.getTarget().invokeExact();
+ return new AccessorInvoker(method, getterFunction);
+ } catch (IllegalAccessException e) {
+ throw new IllegalArgumentException("Unable to create function for " + targetClass + " at " + method.getName(), e);
+ }
+ }
+
+ /**
+ * Gets the lookup object for a class.
+ * If the class is not accessible, a private lookup is created.
+ *
+ * @param targetClass the class to get the lookup for
+ * @return the lookup object
+ */
+ private static MethodHandles.Lookup getLookup(Class> targetClass) {
+ MethodHandles.Lookup lookupMe = MethodHandles.lookup();
+
+ try {
+ return MethodHandles.privateLookupIn(targetClass, lookupMe);
+ } catch (IllegalAccessException e) {
+ return lookupMe;
+ }
+ }
+}
diff --git a/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonIgnore.java b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonIgnore.java
new file mode 100644
index 0000000..02bba21
--- /dev/null
+++ b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonIgnore.java
@@ -0,0 +1,14 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An annotation to specify that a field should be ignored when serializing or deserializing JSON.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface JsonIgnore {
+}
diff --git a/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonIncludeNonNull.java b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonIncludeNonNull.java
new file mode 100644
index 0000000..b5fcb77
--- /dev/null
+++ b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonIncludeNonNull.java
@@ -0,0 +1,15 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An annotation to specify that a field should be included in the JSON output only if it is not null.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD})
+public @interface JsonIncludeNonNull {
+
+}
diff --git a/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonParsingException.java b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonParsingException.java
new file mode 100644
index 0000000..0673125
--- /dev/null
+++ b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonParsingException.java
@@ -0,0 +1,15 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+/**
+ * An exception that is thrown when there is an error parsing a JSON string.
+ */
+public class JsonParsingException extends Exception {
+
+ public JsonParsingException(String message) {
+ super(message);
+ }
+
+ public JsonParsingException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonRaw.java b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonRaw.java
new file mode 100644
index 0000000..582e21f
--- /dev/null
+++ b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/JsonRaw.java
@@ -0,0 +1,16 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An annotation to specify that a field should be serialized as raw JSON.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface JsonRaw {
+ boolean includeKey() default false;
+
+}
diff --git a/src/main/java/net/jonathangiles/tools/teenyhttpd/json/TeenyJson.java b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/TeenyJson.java
new file mode 100644
index 0000000..6414542
--- /dev/null
+++ b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/TeenyJson.java
@@ -0,0 +1,497 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+
+import net.jonathangiles.tools.teenyhttpd.implementation.ParameterizedTypeHelper;
+import net.jonathangiles.tools.teenyhttpd.implementation.ReflectionUtils;
+
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.text.ParseException;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+/**
+ * A simple JSON serializer and deserializer.
+ */
+public final class TeenyJson {
+
+ private final JsonEncoder encoder;
+ private final Map, ValueParser>> parsers = new ConcurrentHashMap<>();
+
+ public TeenyJson() {
+ encoder = new JsonEncoder();
+ }
+
+ /**
+ * Serialize the given object to a JSON string.
+ * @param value the object to serialize
+ * @return the serialized JSON string
+ */
+ public String writeValueAsString(Object value) {
+ return encoder.writeValueAsString(value);
+ }
+
+ /**
+ * Serialize the given object to a JSON string and write it to the given output stream.
+ * @param outputStream the output stream to write to
+ * @param value the object to serialize
+ * @throws IOException if a problem occurs during writing
+ */
+ public void writeValue(BufferedOutputStream outputStream, Object value) throws IOException {
+ if (value == null) return;
+ outputStream.write(writeValueAsString(value).getBytes());
+ }
+
+ /**
+ * Read a JSON string and return a collection of objects of the given type.
+ *
+ * @param collectionType one of List or Set
+ * @param json the JSON string to parse
+ * @return the parsed object which could be null.
+ */
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ public K readCollection(String json, Class extends Collection> collectionType, Class type) {
+ if (json == null) return null;
+
+ Object result = new JsonDecoder(json)
+ .read();
+
+ if (result == null) {
+ return null;
+ }
+
+ List> list = (List>) result;
+
+ if (List.class.isAssignableFrom(collectionType)) {
+ return (K) list.stream()
+ .map(o -> parseObject(o, type))
+ .collect(Collectors.toList());
+ }
+
+ if (Set.class.isAssignableFrom(collectionType)) {
+ return (K) list.stream()
+ .map(o -> parseObject(o, type))
+ .collect(Collectors.toSet());
+ }
+
+ throw new IllegalStateException("Unsupported collection type: " + collectionType.getName());
+ }
+
+ /**
+ *
+ * Read a JSON string and return an object of the given type.
+ *
+ * @param json the JSON string to parse
+ * @param type the type of the object to parse
+ *
+ */
+ @SuppressWarnings("unchecked")
+ public T readValue(String json, Class type) {
+ if (json == null) return null;
+
+ if (type == String.class) {
+ return (T) json;
+ }
+
+ Object result = new JsonDecoder(json).read();
+
+ if (result == null) {
+ return null;
+ }
+ //return the parsed map
+ if (type == Map.class && result instanceof Map) {
+ return (T) result;
+ }
+
+ return parseObject(result, type);
+ }
+
+ @SuppressWarnings("unchecked")
+ public T readValue(String json, Type type) throws JsonParsingException {
+ if (json == null) return null;
+ //if the type is string, just return the string
+ if (type == String.class) {
+ return (T) json;
+ }
+
+ Object result = new JsonDecoder(json).read();
+
+ if (result == null) {
+ return null;
+ }
+
+ if (type instanceof Class>) {
+ return (T) parse(result, type);
+ }
+
+ throw new JsonParsingException("Unsupported type: " + type.getTypeName() + " " + type.getClass().getName());
+ }
+
+
+ /**
+ * Register a custom serializer for the given class.
+ *
+ * @param clazz the class to register
+ */
+ @SuppressWarnings("UnusedReturnValue")
+ public TeenyJson registerSerializer(Class clazz, ValueSerializer serializer) {
+ encoder.registerSerializer(clazz, serializer);
+ return this;
+ }
+
+ /**
+ * Register a custom parser for the given class.
+ *
+ * @param clazz the class to register
+ */
+ @SuppressWarnings("unused")
+ public TeenyJson registerParser(Class clazz, ValueParser parser) {
+ parsers.put(clazz, parser);
+ return this;
+ }
+
+ /**
+ * Parses an object into a target type.
+ * @param object the object to parse
+ * @param type the target type
+ * @return the parsed object or null if the object is null
+ * @param the type of the target
+ */
+ @SuppressWarnings("unchecked")
+ private T parseObject(Object object, Class type) {
+ if (object == null) return null;
+
+ T instance = ReflectionUtils.newInstance(type);
+
+ Map fields = ReflectionUtils.getFields(type);
+ Map mutators = ReflectionUtils.getMutators(type);
+ Map map = (Map) object;
+
+ for (Map.Entry entry : fields.entrySet()) {
+ Object source = map.get(entry.getKey());
+
+ if (source == null) continue;
+
+ write(entry.getValue(), mutators.get(entry.getKey()), source, instance);
+ }
+
+ return instance;
+ }
+
+ /**
+ * Writes a value to a field or method of an instance, is something goes wrong logs the error,
+ * but does not throw an exception.
+ *
+ * @param field the field to write if the method is missing
+ * @param method the method to write
+ * @param value the value to write
+ * @param instance the instance to write to
+ */
+ private void write(Field field, Method method, Object value, Object instance) {
+ try {
+ if (method != null) {
+ method.setAccessible(true);
+
+ if (method.isAnnotationPresent(JsonDeserialize.class)) {
+ JsonDeserialize annotation = method.getAnnotation(JsonDeserialize.class);
+
+ if (annotation.as() != Object.class) {
+ method.invoke(instance, parse(value, annotation.as()));
+ } else if (annotation.contentAs() != Object.class) {
+ ParameterizedTypeHelper helper = ReflectionUtils.getParameterType(method.getGenericParameterTypes()[0]);
+ helper = helper.withFirstType(annotation.contentAs());
+ method.invoke(instance, parse(value, helper));
+ }
+
+ return;
+ }
+
+ try {
+ method.invoke(instance, parse(value, method.getGenericParameterTypes()[0]));
+ } catch (JsonParsingException e) {
+ throw new JsonParsingException("Failed to write field: " + field.getName() + " on " + field.getDeclaringClass(), e);
+ }
+ return;
+ }
+
+ // if the method is missing, try to set the field directly
+ field.setAccessible(true);
+ field.set(instance, parse(value, field.getType()));
+
+ } catch (Exception ex) {
+ Logger.getLogger(TeenyJson.class.getName())
+ .log(Level.SEVERE, "Failed to set field " + field.getName() + " on " + instance.getClass().getName(), ex);
+ }
+ }
+
+ /**
+ * Parses a value into a target type.
+ *
+ * @param value the value to parse
+ * @param target the target type
+ * @return the parsed value or null if the value is null or the target type is not supported
+ * @throws IllegalStateException if the target type is not supported
+ * @throws JsonParsingException if a problem occurs during parsing
+ */
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private Object parse(Object value, Type target) throws JsonParsingException {
+ if (target instanceof ParameterizedType) {
+ return parseCollectionOrMap(value, ReflectionUtils.getParameterType(target));
+ }
+
+ if (target instanceof ParameterizedTypeHelper) {
+ return parseCollectionOrMap(value, (ParameterizedTypeHelper) target);
+ }
+
+ if (target instanceof Class>) {
+ Class> targetClass = (Class>) target;
+
+ if (targetClass.isInterface()) {
+ throw new JsonParsingException("Unsupported type: " + target.getTypeName() + " " + target.getClass().getName());
+ }
+
+ if (targetClass.isPrimitive()) {
+ return parseSimple(value, targetClass);
+ }
+
+ if (targetClass.isArray()) {
+ return parseArray(value, ReflectionUtils.getArrayType(target));
+ }
+
+ if (ReflectionUtils.isEnum(targetClass)) {
+ return Enum.valueOf((Class) target, value.toString());
+ }
+
+ if (!targetClass.getName().startsWith("java")) {
+ return parseObject(value, targetClass);
+ }
+
+ return parseSimple(value, targetClass);
+ }
+
+ throw new JsonParsingException("Unsupported type: " + target.getTypeName() + " " + target.getClass().getName());
+ }
+
+ private Object parseArray(Object value, Class> componentType) throws JsonParsingException {
+ if (value == null) return null;
+
+ List> list = (List>) value;
+
+ Object array = java.lang.reflect.Array.newInstance(componentType, list.size());
+
+ for (int i = 0; i < list.size(); i++) {
+ java.lang.reflect.Array.set(array, i, parse(list.get(i), componentType));
+ }
+
+ return array;
+ }
+
+ /**
+ * Parses a value into a collection or map.
+ *
+ * @param value the value to parse
+ * @param helper the parameterized type helper
+ * @return the parsed value or null if the value is null or the target type is not supported
+ * @throws JsonParsingException if a problem occurs during parsing
+ */
+ @SuppressWarnings("unchecked")
+ private Object parseCollectionOrMap(Object value, ParameterizedTypeHelper helper) throws JsonParsingException {
+ if (helper.getParentType() == null)
+ throw new JsonParsingException("Type is not a ParameterizedType: " + helper);
+
+ if (helper.isParentTypeOf(List.class)) {
+ List> list = (List>) value;
+ List parsedList;
+
+ if (helper.getParentType() == ArrayList.class || helper.getParentType() == List.class) {
+ parsedList = new ArrayList<>();
+ } else {
+ parsedList = (List) ReflectionUtils.newInstance(helper.getParentType());
+ }
+
+ for (Object o : list) {
+ parsedList.add(parse(o, helper.getFirstType()));
+ }
+
+ return parsedList;
+ }
+
+ if (helper.isParentTypeOf(Set.class)) {
+ List> list = (List>) value;
+ Set parsedSet;
+
+ if (helper.getParentType() == HashSet.class || helper.getParentType() == Set.class) {
+ parsedSet = new HashSet<>();
+ } else {
+ parsedSet = (Set) ReflectionUtils.newInstance(helper.getParentType());
+ }
+
+ for (Object o : list) {
+ parsedSet.add(parse(o, helper.getFirstType()));
+ }
+
+ return parsedSet;
+ }
+
+ if (helper.isParentTypeOf(Map.class)) {
+ Map, ?> map = (Map, ?>) value;
+ //json only supports string keys
+ Map result = new HashMap<>();
+
+ for (Map.Entry, ?> entry : map.entrySet()) {
+ result.put(entry.getKey().toString(), parse(entry.getValue(), helper.getSecondType()));
+ }
+
+ return result;
+ }
+
+ throw new JsonParsingException("Unsupported parameterized type: " + helper.getParentType());
+ }
+
+
+ /**
+ * Parses a value into a primitive target type such as int, long, double, float, or boolean.
+ *
+ * @param obj the value to parse
+ * @param target the target type
+ * @return the parsed value or the default value for the target type if the value is null
+ * @throws NumberFormatException if the value cannot be parsed into a number if the target is a number
+ * @throws IllegalStateException if the target type is not supported
+ */
+ private Object parsePrimitive(Object obj, Class> target) {
+ String value = obj == null ? null : obj.toString().trim();
+
+ if (target == int.class) {
+ if (value == null) return 0;
+
+ return Integer.parseInt(value);
+ }
+
+ if (target == long.class) {
+ if (value == null) return 0L;
+
+ return Long.parseLong(value);
+ }
+
+ if (target == double.class) {
+ if (value == null) return 0.0;
+
+ return Double.parseDouble(value);
+ }
+
+ if (target == float.class) {
+ if (value == null) return 0.0f;
+
+ return Float.parseFloat(value);
+ }
+
+ if (target == boolean.class) {
+
+ if (value == null) return false;
+
+ return Boolean.parseBoolean(value);
+ }
+
+ throw new IllegalStateException("Unsupported primitive type: " + target.getName());
+ }
+
+ /**
+ * Parses a simple value into a target type.
+ *
+ * @param value the value to parse
+ * @param target the target type
+ * @throws RuntimeException if a problem occurs during parsing
+ * @return the parsed value or null if the value is null or the target type is not supported
+ */
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private Object parseSimple(Object value, Class> target) {
+ if (value == null) {
+ return null;
+ }
+
+ if (target == String.class) {
+ return value.toString();
+ }
+
+ if (target.isPrimitive()) {
+ return parsePrimitive(value, target);
+ }
+
+ if (target == Integer.class) {
+ return Integer.parseInt(value.toString());
+ }
+
+ if (target == BigDecimal.class) {
+ return new BigDecimal(value.toString());
+ }
+
+ if (target == BigInteger.class) {
+ return new BigInteger(value.toString());
+ }
+
+ if (target == Long.class) {
+ return Long.parseLong(value.toString());
+ }
+
+ if (target == Double.class) {
+ return Double.parseDouble(value.toString());
+ }
+
+ if (target == Float.class) {
+ return Float.parseFloat(value.toString());
+ }
+
+ if (target == Boolean.class) {
+ return Boolean.parseBoolean(value.toString());
+ }
+
+ if (target == LocalDateTime.class) {
+ return LocalDateTime.parse(value.toString());
+ }
+
+ if (target == LocalDate.class) {
+ return LocalDate.parse(value.toString());
+ }
+
+ if (target == LocalTime.class) {
+ return LocalTime.parse(value.toString());
+ }
+
+ if (target == Date.class) {
+ try {
+ return encoder.getDateFormatter()
+ .parse(value.toString());
+ } catch (ParseException e) {
+ return null;
+ }
+ }
+
+ if (target.isEnum()) {
+ return Enum.valueOf((Class) target, value.toString());
+ }
+
+ ValueParser> parser = parsers.get(target);
+
+ if (parser != null) {
+ return parser.parse(value);
+ }
+
+ Logger.getLogger(TeenyJson.class.getName())
+ .log(Level.WARNING, "not implemented: " + target.getName());
+
+ return null;
+ }
+
+}
diff --git a/src/main/java/net/jonathangiles/tools/teenyhttpd/json/TeenyJsonMessageConverter.java b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/TeenyJsonMessageConverter.java
new file mode 100644
index 0000000..5173206
--- /dev/null
+++ b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/TeenyJsonMessageConverter.java
@@ -0,0 +1,31 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import net.jonathangiles.tools.teenyhttpd.model.MessageConverter;
+
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.lang.reflect.Type;
+
+public final class TeenyJsonMessageConverter implements MessageConverter {
+
+ public final TeenyJson teenyJson = new TeenyJson();
+
+ @Override
+ public String getContentType() {
+ return "application/json";
+ }
+
+ @Override
+ public void write(Object value, BufferedOutputStream dataOut) throws IOException {
+ teenyJson.writeValue(dataOut, value);
+ }
+
+ @Override
+ public Object read(String value, Type type) {
+ try {
+ return teenyJson.readValue(value, type);
+ } catch (JsonParsingException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/src/main/java/net/jonathangiles/tools/teenyhttpd/json/ValueParser.java b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/ValueParser.java
new file mode 100644
index 0000000..3b19b34
--- /dev/null
+++ b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/ValueParser.java
@@ -0,0 +1,10 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+/**
+ * A simple interface for parsing a value into T, where the value could be a Map, List, Null or String.
+ */
+public interface ValueParser {
+
+ T parse(Object value);
+
+}
diff --git a/src/main/java/net/jonathangiles/tools/teenyhttpd/json/ValueSerializer.java b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/ValueSerializer.java
new file mode 100644
index 0000000..19f3c45
--- /dev/null
+++ b/src/main/java/net/jonathangiles/tools/teenyhttpd/json/ValueSerializer.java
@@ -0,0 +1,9 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+/**
+ * A simple interface for serializing a value into a String.
+ */
+public interface ValueSerializer {
+ String serialize(T value);
+
+}
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/Pet.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/Pet.java
index 40e5b7d..3ca3889 100644
--- a/src/test/java/net/jonathangiles/tools/teenyhttpd/Pet.java
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/Pet.java
@@ -4,9 +4,9 @@
public class Pet {
- private String name;
- private int age;
- private String type;
+ public String name;
+ public int age;
+ public String type;
public Pet(String name, int age, String type) {
this.name = name;
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/ProductDto.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/ProductDto.java
index 8f30e3f..e122d20 100644
--- a/src/test/java/net/jonathangiles/tools/teenyhttpd/ProductDto.java
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/ProductDto.java
@@ -1,10 +1,18 @@
package net.jonathangiles.tools.teenyhttpd;
+
+import net.jonathangiles.tools.teenyhttpd.json.JsonIncludeNonNull;
+
+import java.util.Map;
+
+@JsonIncludeNonNull
public class ProductDto {
- private int id;
- private String name;
- private int price;
+ public int id;
+ public String name;
+ public int price;
+ public Map perks;
+
public ProductDto() {
}
@@ -15,6 +23,22 @@ public ProductDto(int id, String name, int price) {
this.price = price;
}
+
+ public Map getPerks() {
+ return perks;
+ }
+
+ public ProductDto put(String key, Object value) {
+
+ if (perks == null) {
+ perks = new java.util.HashMap<>();
+ }
+
+ perks.put(key, value);
+
+ return this;
+ }
+
public int getId() {
return id;
}
@@ -41,4 +65,14 @@ public ProductDto setPrice(int price) {
this.price = price;
return this;
}
+
+ @Override
+ public String toString() {
+ return "ProductDto{" +
+ "id=" + id +
+ ", name='" + name + '\'' +
+ ", price=" + price +
+ ", perks=" + perks +
+ '}';
+ }
}
\ No newline at end of file
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/TeenyApplicationTest.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/TeenyApplicationTest.java
index aa1fb21..d1b2aa3 100644
--- a/src/test/java/net/jonathangiles/tools/teenyhttpd/TeenyApplicationTest.java
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/TeenyApplicationTest.java
@@ -27,7 +27,6 @@ public class TeenyApplicationTest {
private static final int TEST_PORT = 8080;
-
private static class ProtocolBufferMessageConverter implements MessageConverter {
@Override
@@ -52,12 +51,16 @@ public void setup() {
System.setProperty("banner", "false");
TeenyApplication.start()
- .registerMessageConverter(new GsonMessageConverter())
.registerMessageConverter(new ProtocolBufferMessageConverter())
.register(new StoreController());
}
+ public static void main(String[] args) {
+ TeenyApplication.start()
+ .register(new StoreController());
+ }
+
@AfterEach
public void tearDown() {
TeenyApplication.stop();
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/TeenyJsonConverter.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/TeenyJsonConverter.java
new file mode 100644
index 0000000..f36633d
--- /dev/null
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/TeenyJsonConverter.java
@@ -0,0 +1,39 @@
+package net.jonathangiles.tools.teenyhttpd;
+
+import net.jonathangiles.tools.teenyhttpd.json.TeenyJson;
+import net.jonathangiles.tools.teenyhttpd.model.MessageConverter;
+
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.lang.reflect.Type;
+
+public class TeenyJsonConverter implements MessageConverter {
+
+ final TeenyJson teenyJson = new TeenyJson();
+
+ @Override
+ public String getContentType() {
+ return "application/json";
+ }
+
+ @Override
+ public void write(Object value, BufferedOutputStream dataOut) throws IOException {
+
+
+ if (value instanceof String) {
+ dataOut.write(((String) value).getBytes());
+ return;
+ }
+
+ dataOut.write(teenyJson.writeValueAsString(value).getBytes());
+ }
+
+ @Override
+ public Object read(String value, Type type) {
+ try {
+ return teenyJson.readValue(value, type);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/json/Automakers.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/Automakers.java
new file mode 100644
index 0000000..f16988b
--- /dev/null
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/Automakers.java
@@ -0,0 +1,6 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+public enum Automakers {
+ FORD, TOYOTA, HONDA, BMW, MERCEDES, AUDI, VOLKSWAGEN, TESLA, NISSAN, PORSCHE, JAGUAR, LAND_ROVER, FERRARI, LAMBORGHINI,
+ MASERATI, ALFA_ROMEO, FIAT, CHRYSLER, DODGE, JEEP, RAM, CHEVROLET, GMC, BUICK, CADILLAC, LINCOLN;
+}
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/json/C.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/C.java
new file mode 100644
index 0000000..010c3cd
--- /dev/null
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/C.java
@@ -0,0 +1,9 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+public interface C {
+
+ String getName();
+
+ int getAge();
+
+}
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/json/ComplexObject.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/ComplexObject.java
new file mode 100644
index 0000000..342f43d
--- /dev/null
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/ComplexObject.java
@@ -0,0 +1,309 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.*;
+
+public class ComplexObject {
+
+ private String string;
+ private boolean aBoolean;
+ private Boolean aBoolean2;
+ private int anInt;
+ private Integer anInteger;
+ private long aLong;
+ private Long aLong2;
+ private float aFloat;
+ private Float aFloat2;
+ private double aDouble;
+ private Double aDouble2;
+ private BigDecimal bigDecimal;
+ private BigInteger bigInteger;
+ private LocalDate localDate;
+ private LocalDateTime localDateTime;
+ private LocalTime localTime;
+ private Automakers automaker;
+ private List list;
+ private Set set;
+ private Map map;
+ private ArrayList arrayList;
+ private Date date;
+ private String[] array;
+ private ObjectC[] arrayOfObjectC;
+ private LinkedList linkedList;
+ private LinkedHashSet linkedHashSet;
+
+ public ComplexObject() {
+ }
+
+ public ComplexObject setLinkedList(LinkedList linkedList) {
+ this.linkedList = linkedList;
+ return this;
+ }
+
+ public ComplexObject setLinkedHashSet(LinkedHashSet linkedHashSet) {
+ this.linkedHashSet = linkedHashSet;
+ return this;
+ }
+
+ public LinkedList getLinkedList() {
+ return linkedList;
+ }
+
+ public LinkedHashSet getLinkedHashSet() {
+ return linkedHashSet;
+ }
+
+ public String getString() {
+ return string;
+ }
+
+ public ComplexObject setString(String string) {
+ this.string = string;
+ return this;
+ }
+
+ public boolean isaBoolean() {
+ return aBoolean;
+ }
+
+ public ComplexObject setaBoolean(boolean aBoolean) {
+ this.aBoolean = aBoolean;
+ return this;
+ }
+
+ public Boolean getaBoolean2() {
+ return aBoolean2;
+ }
+
+ public ComplexObject setaBoolean2(Boolean aBoolean2) {
+ this.aBoolean2 = aBoolean2;
+ return this;
+ }
+
+ public int getAnInt() {
+ return anInt;
+ }
+
+ public ComplexObject setAnInt(int anInt) {
+ this.anInt = anInt;
+ return this;
+ }
+
+ public Integer getAnInteger() {
+ return anInteger;
+ }
+
+ public ComplexObject setAnInteger(Integer anInteger) {
+ this.anInteger = anInteger;
+ return this;
+ }
+
+ public long getaLong() {
+ return aLong;
+ }
+
+ public ComplexObject setaLong(long aLong) {
+ this.aLong = aLong;
+ return this;
+ }
+
+ public Long getaLong2() {
+ return aLong2;
+ }
+
+ public ComplexObject setaLong2(Long aLong2) {
+ this.aLong2 = aLong2;
+ return this;
+ }
+
+ public float getaFloat() {
+ return aFloat;
+ }
+
+ public ComplexObject setaFloat(float aFloat) {
+ this.aFloat = aFloat;
+ return this;
+ }
+
+ public Float getaFloat2() {
+ return aFloat2;
+ }
+
+ public ComplexObject setaFloat2(Float aFloat2) {
+ this.aFloat2 = aFloat2;
+ return this;
+ }
+
+ public double getaDouble() {
+ return aDouble;
+ }
+
+ public ComplexObject setaDouble(double aDouble) {
+ this.aDouble = aDouble;
+ return this;
+ }
+
+ public Double getaDouble2() {
+ return aDouble2;
+ }
+
+ public ComplexObject setaDouble2(Double aDouble2) {
+ this.aDouble2 = aDouble2;
+ return this;
+ }
+
+ public BigDecimal getBigDecimal() {
+ return bigDecimal;
+ }
+
+ public ComplexObject setBigDecimal(BigDecimal bigDecimal) {
+ this.bigDecimal = bigDecimal;
+ return this;
+ }
+
+ public BigInteger getBigInteger() {
+ return bigInteger;
+ }
+
+ public ComplexObject setBigInteger(BigInteger bigInteger) {
+ this.bigInteger = bigInteger;
+ return this;
+ }
+
+ public LocalDate getLocalDate() {
+ return localDate;
+ }
+
+ public ComplexObject setLocalDate(LocalDate localDate) {
+ this.localDate = localDate;
+ return this;
+ }
+
+ public LocalDateTime getLocalDateTime() {
+ return localDateTime;
+ }
+
+ public ComplexObject setLocalDateTime(LocalDateTime localDateTime) {
+ this.localDateTime = localDateTime;
+ return this;
+ }
+
+ public LocalTime getLocalTime() {
+ return localTime;
+ }
+
+ public ComplexObject setLocalTime(LocalTime localTime) {
+ this.localTime = localTime;
+ return this;
+ }
+
+ public Automakers getAutomaker() {
+ return automaker;
+ }
+
+ public ComplexObject setAutomaker(Automakers automaker) {
+ this.automaker = automaker;
+ return this;
+ }
+
+ public List getList() {
+ return list;
+ }
+
+ public ComplexObject setList(List list) {
+ this.list = list;
+ return this;
+ }
+
+ public Set getSet() {
+ return set;
+ }
+
+ public ComplexObject setSet(Set set) {
+ this.set = set;
+ return this;
+ }
+
+ public Map getMap() {
+ return map;
+ }
+
+ public ComplexObject setMap(Map map) {
+ this.map = map;
+ return this;
+ }
+
+ public ArrayList getArrayList() {
+ return arrayList;
+ }
+
+ public ComplexObject setArrayList(ArrayList arrayList) {
+ this.arrayList = arrayList;
+ return this;
+ }
+
+ public Date getDate() {
+ return date;
+ }
+
+ public ComplexObject setDate(Date date) {
+ this.date = date;
+ return this;
+ }
+
+ public String[] getArray() {
+ return array;
+ }
+
+ public ComplexObject setArray(String[] array) {
+ this.array = array;
+ return this;
+ }
+
+ public ObjectC[] getArrayOfObjectC() {
+ return arrayOfObjectC;
+ }
+
+ public ComplexObject setArrayOfObjectC(ObjectC[] arrayOfObjectC) {
+ this.arrayOfObjectC = arrayOfObjectC;
+ return this;
+ }
+
+
+
+ @Override
+ public String toString() {
+ return "ComplexObject{" +
+ "string='" + string + '\'' +
+ ", aBoolean=" + aBoolean +
+ ", aBoolean2=" + aBoolean2 +
+ ", anInt=" + anInt +
+ ", anInteger=" + anInteger +
+ ", aLong=" + aLong +
+ ", aLong2=" + aLong2 +
+ ", aFloat=" + aFloat +
+ ", aFloat2=" + aFloat2 +
+ ", aDouble=" + aDouble +
+ ", aDouble2=" + aDouble2 +
+ ", bigDecimal=" + bigDecimal +
+ ", bigInteger=" + bigInteger +
+ ", localDate=" + localDate +
+ ", localDateTime=" + localDateTime +
+ ", localTime=" + localTime +
+ ", automaker=" + automaker +
+ ", list=" + list +
+ ", set=" + set +
+ ", map=" + map +
+ ", arrayList=" + arrayList +
+ ", date=" + date +
+ ", array=" + Arrays.toString(array) +
+ ", arrayOfObjectC=" + Arrays.toString(arrayOfObjectC) +
+ ", linkedList=" + linkedList +
+ ", linkedHashSet=" + linkedHashSet +
+ '}';
+ }
+}
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/json/JsonBenchmarks.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/JsonBenchmarks.java
new file mode 100644
index 0000000..45de1df
--- /dev/null
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/JsonBenchmarks.java
@@ -0,0 +1,69 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Mode;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.*;
+
+public class JsonBenchmarks {
+
+ static final ComplexObject complexObject = new ComplexObject()
+ .setString("string :{}")
+ .setAnInt(1)
+ .setAnInteger(2)
+ .setaLong(2)
+ .setaLong2(2L)
+ .setaFloat(1.0f)
+ .setaFloat2(1.0f)
+ .setaDouble(1.0)
+ .setaDouble2(1.0)
+ .setaBoolean(true)
+ .setaBoolean2(true)
+ .setBigDecimal(BigDecimal.TEN)
+ .setBigInteger(BigDecimal.TEN.toBigInteger())
+ .setLocalDate(LocalDate.now())
+ .setLocalDateTime(LocalDate.now().atStartOfDay())
+ .setLocalTime(LocalTime.now())
+ .setArray(new String[]{"A", "B", "C"})
+ .setLinkedList(new LinkedList<>(List.of("A", "B", "C")))
+ .setLinkedHashSet(new LinkedHashSet<>(List.of("A", "B", "C")))
+ .setArrayOfObjectC(new ObjectC[]{new ObjectC("John Doe", 23),
+ new ObjectC("Jane Doe", 25),
+ new ObjectC("John Smith", 30)})
+ .setArrayList(new ArrayList<>(List.of(1, 2, 3, 4, 5)))
+ .setDate(new java.util.Date())
+ .setAutomaker(Automakers.TOYOTA)
+ .setList(List.of("A", "B", "C"))
+ .setSet(Set.of("A", "B", "C"))
+ .setMap(Map.of("A", new ObjectC("John Doe", 23),
+ "B", new ObjectC("Jane Doe", 25),
+ "C", new ObjectC("John Smith", 30)));
+
+ static final TeenyJson teenyJson = new TeenyJson();
+
+ static final String json = "{\"localDateTime\": \"2024-03-10T00:00\", \"linkedHashSet\": [\"A\", \"B\", \"C\"], \"date\": \"10-03-2024 02:19:07\", \"aFloat2\": 1.0, \"string\": \"string :{}\", \"aLong\": 2, \"anInt\": 1, \"arrayOfObjectC\": [{\"name\": \"John Doe\", \"age\": 23}, {\"name\": \"Jane Doe\", \"age\": 25}, {\"name\": \"John Smith\", \"age\": 30}], \"aDouble\": 1.0, \"anInteger\": 2, \"aBoolean2\": true, \"array\": [\"A\", \"B\", \"C\"], \"arrayList\": [1, 2, 3, 4, 5], \"localDate\": \"2024-03-10\", \"bigDecimal\": 10, \"map\": {\"A\": {\"name\": \"John Doe\", \"age\": 23}, \"B\": {\"name\": \"Jane Doe\", \"age\": 25}, \"C\": {\"name\": \"John Smith\", \"age\": 30}}, \"linkedList\": [\"A\", \"B\", \"C\"], \"aBoolean\": true, \"aDouble2\": 1.0, \"set\": [\"A\", \"B\", \"C\"], \"aFloat\": 1.0, \"bigInteger\": 10, \"list\": [\"A\", \"B\", \"C\"], \"localTime\": \"02:19:07.036590\", \"automaker\": \"TOYOTA\", \"aLong2\": 2}";
+
+ public static void main(String[] args) throws Exception {
+ org.openjdk.jmh.Main.main(args);
+ }
+
+ @Benchmark
+ @BenchmarkMode(Mode.Throughput)
+ @Fork(value = 1, warmups = 2)
+ public void encodingBenchmark() {
+ teenyJson.writeValueAsString(complexObject);
+ }
+
+ @Benchmark
+ @BenchmarkMode(Mode.Throughput)
+ @Fork(value = 1, warmups = 2)
+ public void decodingBenchmark() {
+ teenyJson.readValue(json, ComplexObject.class);
+ }
+
+}
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/json/JsonDecoderTest.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/JsonDecoderTest.java
new file mode 100644
index 0000000..7f5f058
--- /dev/null
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/JsonDecoderTest.java
@@ -0,0 +1,567 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import com.google.gson.Gson;
+import net.jonathangiles.tools.teenyhttpd.Pet;
+import net.jonathangiles.tools.teenyhttpd.ProductDto;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.*;
+
+public class JsonDecoderTest {
+
+ @Test
+ void testDecodeFromGson() {
+ String json = new Gson()
+ .toJson(new ProductDto(1, "Product 1", 100));
+
+ Object value = new JsonDecoder(json)
+ .read();
+
+ Assertions.assertInstanceOf(Map.class, value);
+
+ Map, ?> map = (Map, ?>) value;
+
+ Assertions.assertEquals(3, map.size());
+
+ System.out.println(value);
+ }
+
+ @Test
+ void testEncodeAndDecode() {
+ String json = new TeenyJson()
+ .writeValueAsString(new ProductDto(1, "Product 1", 100));
+
+ System.out.println(json);
+
+ Object value = new JsonDecoder(json)
+ .read();
+
+ System.out.println(value);
+
+ Assertions.assertInstanceOf(Map.class, value);
+
+ Map, ?> map = (Map, ?>) value;
+
+ Assertions.assertEquals(3, map.size());
+
+ }
+
+ /**
+ * Test that a simple object can be parsed
+ */
+ @Test
+ void testParseProduct() {
+ String json = new Gson()
+ .toJson(new ProductDto(1, "Product 1", 100));
+
+ ProductDto product = new TeenyJson()
+ .readValue(json, ProductDto.class);
+
+ System.out.println(product);
+
+ Assertions.assertEquals(1, product.getId());
+ Assertions.assertEquals("Product 1", product.getName());
+ Assertions.assertEquals(100, product.getPrice());
+ }
+
+ /**
+ * Test that a list of objects can be parsed
+ */
+ @Test
+ void testList() {
+ String json = new Gson()
+ .toJson(List.of(new ProductDto(1, "Product 1", 100),
+ new ProductDto(1, "Product 1", 100),
+ new ProductDto(1, "Product 1", 100)));
+
+ Object value = new JsonDecoder(json)
+ .read();
+
+ Assertions.assertInstanceOf(List.class, value);
+
+ List> list = (List>) value;
+
+ Assertions.assertEquals(3, list.size());
+
+ System.out.println(value);
+
+ for (Object o : list) {
+ Assertions.assertInstanceOf(Map.class, o);
+
+ Map, ?> map = (Map, ?>) o;
+
+ Assertions.assertEquals(3, map.size());
+ }
+ }
+
+ @Test
+ void testArrayTeenyJson() {
+ String json = new TeenyJson()
+ .writeValueAsString(List.of(new ProductDto(1, "Product 1", 100),
+ new ProductDto(1, "Product 1", 100),
+ new ProductDto(1, "Product 1", 100)));
+
+ System.out.println(json);
+
+ Object value = new JsonDecoder(json)
+ .read();
+
+ System.out.println(value);
+
+ Assertions.assertInstanceOf(List.class, value);
+
+ List> list = (List>) value;
+
+ Assertions.assertEquals(3, list.size());
+
+ System.out.println(value);
+
+ for (Object o : list) {
+ Assertions.assertInstanceOf(Map.class, o);
+
+ Map, ?> map = (Map, ?>) o;
+
+ Assertions.assertEquals(3, map.size());
+ }
+ }
+
+ @Test
+ void testParseArray() {
+ String json = new Gson()
+ .toJson(List.of(new ProductDto(1, "Product 1", 100),
+ new ProductDto(1, "Product 1", 100),
+ new ProductDto(1, "Product 1", 100)));
+
+ List products = new TeenyJson()
+ .readCollection(json, List.class, ProductDto.class);
+
+ System.out.println(products);
+
+ Assertions.assertEquals(3, products.size());
+
+ for (ProductDto product : products) {
+ Assertions.assertEquals(1, product.getId());
+ Assertions.assertEquals("Product 1", product.getName());
+ Assertions.assertEquals(100, product.getPrice());
+ }
+ }
+
+ @Test
+ void parseArrayTeenyJson() {
+ String json = new TeenyJson()
+ .writeValueAsString(List.of(new ProductDto(1, "Product 1", 100),
+ new ProductDto(1, "Product 1", 100),
+ new ProductDto(1, "Product 1", 100)));
+
+ List products = new TeenyJson()
+ .readCollection(json, List.class, ProductDto.class);
+
+ System.out.println(products);
+
+ Assertions.assertEquals(3, products.size());
+
+ for (ProductDto product : products) {
+ Assertions.assertEquals(1, product.getId());
+ Assertions.assertEquals("Product 1", product.getName());
+ Assertions.assertEquals(100, product.getPrice());
+ }
+ }
+
+ @Test
+ void testArray2() {
+ String json = new Gson()
+ .toJson(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10));
+
+ Object value = new JsonDecoder(json)
+ .read();
+
+ Assertions.assertInstanceOf(List.class, value);
+
+ List> list = (List>) value;
+
+ Assertions.assertEquals(10, list.size());
+
+ Assertions.assertEquals("1", list.get(0));
+
+ json = new Gson()
+ .toJson(List.of("A", "B", "C", "D"));
+
+ value = new JsonDecoder(json)
+ .read();
+
+ Assertions.assertInstanceOf(List.class, value);
+
+ list = (List>) value;
+
+ Assertions.assertEquals(4, list.size());
+
+ Assertions.assertEquals("A", list.get(0));
+
+ System.out.println(value);
+ }
+
+ @Test
+ void testArray2TeenyJson() {
+ String json = new TeenyJson()
+ .writeValueAsString(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10));
+
+ Object value = new JsonDecoder(json)
+ .read();
+
+ Assertions.assertInstanceOf(List.class, value);
+
+ List> list = (List>) value;
+
+ Assertions.assertEquals(10, list.size());
+
+ Assertions.assertEquals("1", list.get(0));
+
+ json = new TeenyJson()
+ .writeValueAsString(List.of("A", "B", "C", "D"));
+
+
+ value = new JsonDecoder(json)
+ .read();
+
+ Assertions.assertInstanceOf(List.class, value);
+
+ list = (List>) value;
+
+ Assertions.assertEquals(4, list.size());
+
+ Assertions.assertEquals("A", list.get(0));
+
+ System.out.println(value);
+ }
+
+ @Test
+ void testDeepObject() {
+ String json = new Gson()
+ .toJson(new ObjectA("Alex", 25,
+ true, List.of("Code", "Travel"),
+ new ObjectB("Jonathan", 30,
+ new ObjectC("John Doe", 23))));
+
+ Object value = new JsonDecoder(json)
+ .read();
+
+ System.out.println(value);
+
+ Assertions.assertInstanceOf(Map.class, value);
+
+ Map, ?> map = (Map, ?>) value;
+
+ Assertions.assertEquals(5, map.size());
+
+ Assertions.assertInstanceOf(Map.class, map.get("objectB"));
+
+ Map, ?> objectB = (Map, ?>) map.get("objectB");
+
+ Assertions.assertEquals(3, objectB.size());
+
+ Assertions.assertInstanceOf(Map.class, objectB.get("objectC"));
+
+ Map, ?> objectC = (Map, ?>) objectB.get("objectC");
+
+ Assertions.assertEquals(2, objectC.size());
+
+ }
+
+ @Test
+ void testDeepObjectTeenyJson() {
+ String json = new TeenyJson()
+ .writeValueAsString(new ObjectA("Alex", 25,
+ true, List.of("Code", "Travel"),
+ new ObjectB("Jonathan", 30,
+ new ObjectC("John Doe", 23))));
+
+ Object value = new JsonDecoder(json)
+ .read();
+
+ System.out.println(value);
+
+ Assertions.assertInstanceOf(Map.class, value);
+
+ Map, ?> map = (Map, ?>) value;
+
+ Assertions.assertEquals(5, map.size());
+
+ Assertions.assertInstanceOf(Map.class, map.get("objectB"));
+
+ Map, ?> objectB = (Map, ?>) map.get("objectB");
+
+ Assertions.assertEquals(3, objectB.size());
+
+ Assertions.assertInstanceOf(Map.class, objectB.get("objectC"));
+
+ Map, ?> objectC = (Map, ?>) objectB.get("objectC");
+
+ Assertions.assertEquals(2, objectC.size());
+
+ }
+
+ @Test
+ void parseDeepObject() {
+ String json = new Gson()
+ .toJson(new ObjectA("Alex", 25,
+ true, null,
+ new ObjectB("Jonathan", 30,
+ new ObjectC("John Doe", 23))));
+
+ System.out.println(json);
+
+ ObjectA objectA = new TeenyJson()
+ .readValue(json, ObjectA.class);
+
+ System.out.println(objectA);
+
+ Assertions.assertEquals("Alex", objectA.getName());
+ Assertions.assertEquals(25, objectA.getAge());
+ Assertions.assertTrue(objectA.isDeveloper());
+
+ ObjectB objectB = objectA.getObjectB();
+
+ Assertions.assertEquals("Jonathan", objectB.getName());
+ Assertions.assertEquals(30, objectB.getAge());
+
+ ObjectC objectC = objectB.getObjectC();
+
+ Assertions.assertEquals("John Doe", objectC.getName());
+ Assertions.assertEquals(23, objectC.getAge());
+
+ }
+
+ @Test
+ void parseDeepObjectTeenyJson() {
+ String json = new TeenyJson()
+ .writeValueAsString(new ObjectA("Alex", 25,
+ true, null,
+ new ObjectB("Jonathan", 30,
+ new ObjectC("John Doe", 23))));
+
+ String json2 = new Gson()
+ .toJson(new ObjectA("Alex", 25,
+ true, null,
+ new ObjectB("Jonathan", 30,
+ new ObjectC("John Doe", 23))));
+
+ System.out.println(json);
+ System.out.println(json2);
+
+ Assertions.assertEquals(json.length(), json2.length());
+
+
+ ObjectA objectA = new TeenyJson()
+ .readValue(json, ObjectA.class);
+
+ System.out.println(objectA);
+
+ Assertions.assertEquals("Alex", objectA.getName());
+ Assertions.assertEquals(25, objectA.getAge());
+ Assertions.assertTrue(objectA.isDeveloper());
+
+ ObjectB objectB = objectA.getObjectB();
+
+ Assertions.assertEquals("Jonathan", objectB.getName());
+ Assertions.assertEquals(30, objectB.getAge());
+
+ ObjectC objectC = objectB.getObjectC();
+
+ Assertions.assertEquals("John Doe", objectC.getName());
+ Assertions.assertEquals(23, objectC.getAge());
+
+ }
+
+ @Test
+ void testParseFormattedJson() {
+ String json = "{\n" +
+ " \"objectB\": {\n" +
+ " \"objectC\": {\n" +
+ " \"name\": \"John Doe\",\n" +
+ " \"age\": 23\n" +
+ " },\n" +
+ " \"name\": \"Jonathan\",\n" +
+ " \"age\": 30\n" +
+ " },\n" +
+ " \"name\": \"Alex\",\n" +
+ " \"developer\": true,\n" +
+ " \"age\": 25\n" +
+ "}";
+
+ System.out.println("Json: " + json);
+
+ ObjectA objectA = new TeenyJson()
+ .readValue(json, ObjectA.class);
+
+ System.out.println(objectA);
+
+ Assertions.assertEquals("Alex", objectA.getName());
+ Assertions.assertEquals(25, objectA.getAge());
+ Assertions.assertTrue(objectA.isDeveloper());
+
+ ObjectB objectB = objectA.getObjectB();
+
+ Assertions.assertEquals("Jonathan", objectB.getName());
+ Assertions.assertEquals(30, objectB.getAge());
+
+ ObjectC objectC = objectB.getObjectC();
+
+ Assertions.assertEquals("John Doe", objectC.getName());
+ Assertions.assertEquals(23, objectC.getAge());
+
+ }
+
+ @Test
+ void testJsonDeserializeAnnot() {
+ String json = new TeenyJson()
+ .writeValueAsString(new ObjectD()
+ .setName("Alex")
+ .setList(List.of(new ObjectC("John Doe", 23),
+ new ObjectC("Jane Doe", 25),
+ new ObjectC("John Smith", 30)))
+ .setC(new ObjectC("John Doe", 23)));
+
+
+ ObjectD objectD = new TeenyJson()
+ .readValue(json, ObjectD.class);
+
+ System.out.println(objectD);
+
+ Assertions.assertEquals("Alex", objectD.getName());
+
+ C c = objectD.getC();
+
+ Assertions.assertEquals("John Doe", c.getName());
+ Assertions.assertEquals(23, c.getAge());
+ Assertions.assertInstanceOf(ObjectC.class, c);
+
+ List extends C> list = objectD.getList();
+
+ Assertions.assertEquals(3, list.size());
+
+ for (C c1 : list) {
+ Assertions.assertInstanceOf(ObjectC.class, c1);
+ }
+ }
+
+ @Test
+ void testAlias() {
+ Person person = new Person()
+ .setName("Alex")
+ .setAge(25)
+ .setLikesIceCream(true)
+ .setToolList(List.of(new Tool("Mac", "Laptop"),
+ new Tool("IntelliJ", "IDE"),
+ new Tool("Coffee", "Fuel")
+ ))
+ .setBirthDate(LocalDate.of(1995, 1, 1))
+ .setFavoriteSongs(Set.of("Radio GaGa", "Whatever it takes", "Feel Good Inc"))
+ .setPet(new Pet()
+ .setName("Rocky")
+ .setType("Dog")
+ .setAge(1));
+
+ String json = new TeenyJson()
+ .writeValueAsString(person);
+
+ System.out.println(json);
+
+ Person newPerson = new TeenyJson()
+ .readValue(json, Person.class);
+
+ if (!person.equals(newPerson)) {
+ System.out.println(person);
+ System.out.println(newPerson);
+
+ Assertions.fail("Objects are not equal");
+ }
+ }
+
+ /**
+ * {@link ComplexObject} is a complex object with various types of fields
+ * this tests verifies that TeenyJson can parse and serialize this object
+ */
+ @Test
+ void testComplexObject() {
+ ComplexObject complexObject = new ComplexObject()
+ .setString("string :{}")
+ .setAnInt(1)
+ .setAnInteger(2)
+ .setaLong(2)
+ .setaLong2(2L)
+ .setaFloat(1.0f)
+ .setaFloat2(1.0f)
+ .setaDouble(1.0)
+ .setaDouble2(1.0)
+ .setaBoolean(true)
+ .setaBoolean2(true)
+ .setBigDecimal(BigDecimal.TEN)
+ .setBigInteger(BigDecimal.TEN.toBigInteger())
+ .setLocalDate(LocalDate.now())
+ .setLocalDateTime(LocalDate.now().atStartOfDay())
+ .setLocalTime(LocalTime.now())
+ .setArray(new String[]{"A", "B", "C"})
+ .setLinkedList(new LinkedList<>(List.of("A", "B", "C")))
+ .setLinkedHashSet(new LinkedHashSet<>(List.of("A", "B", "C")))
+ .setArrayOfObjectC(new ObjectC[]{new ObjectC("John Doe", 23),
+ new ObjectC("Jane Doe", 25),
+ new ObjectC("John Smith", 30)})
+ .setArrayList(new ArrayList<>(List.of(1, 2, 3, 4, 5)))
+ .setDate(new java.util.Date())
+ .setAutomaker(Automakers.TOYOTA)
+ .setList(List.of("A", "B", "C"))
+ .setSet(Set.of("A", "B", "C"))
+ .setMap(Map.of("A", new ObjectC("John Doe", 23),
+ "B", new ObjectC("Jane Doe", 25),
+ "C", new ObjectC("John Smith", 30)));
+
+ String json = new TeenyJson()
+ .writeValueAsString(complexObject);
+
+ System.out.println("JSON: " + json);
+
+ ComplexObject object = new TeenyJson()
+ .readValue(json, ComplexObject.class);
+
+ System.out.println(object);
+
+ if (complexObject.isaBoolean() != object.isaBoolean()) Assertions.fail("aBoolean");
+ if (complexObject.getAnInt() != object.getAnInt()) Assertions.fail("anInt");
+ if (complexObject.getaLong() != object.getaLong()) Assertions.fail("aLong");
+ if (Float.compare(complexObject.getaFloat(), object.getaFloat()) != 0) Assertions.fail("aFloat");
+ if (Double.compare(complexObject.getaDouble(), object.getaDouble()) != 0) Assertions.fail("aDouble");
+ if (!complexObject.getString().equals(object.getString())) Assertions.fail("String");
+ if (!complexObject.getaBoolean2().equals(object.getaBoolean2())) Assertions.fail("aBoolean2");
+ if (!complexObject.getAnInteger().equals(object.getAnInteger())) Assertions.fail("AnInteger");
+ if (!complexObject.getaLong2().equals(object.getaLong2())) Assertions.fail("aLong2");
+ if (!complexObject.getaFloat2().equals(object.getaFloat2())) Assertions.fail("aFloat2");
+ if (!complexObject.getaDouble2().equals(object.getaDouble2())) Assertions.fail("aDouble2");
+ if (!complexObject.getBigDecimal().equals(object.getBigDecimal())) Assertions.fail("BigDecimal");
+ if (!complexObject.getBigInteger().equals(object.getBigInteger())) Assertions.fail("BigInteger");
+ if (!complexObject.getLocalDate().equals(object.getLocalDate())) Assertions.fail("LocalDate");
+ if (!complexObject.getLocalDateTime().equals(object.getLocalDateTime())) Assertions.fail("LocalDateTime");
+ if (!complexObject.getLocalTime().equals(object.getLocalTime())) Assertions.fail("LocalTime");
+ if (complexObject.getAutomaker() != object.getAutomaker()) Assertions.fail("Automaker");
+ if (!complexObject.getList().equals(object.getList())) Assertions.fail("List");
+ if (!complexObject.getSet().equals(object.getSet())) Assertions.fail("Set");
+ if (!complexObject.getMap().equals(object.getMap())) Assertions.fail("Map");
+ if (!complexObject.getArrayList().equals(object.getArrayList())) Assertions.fail("ArrayList");
+ if (!complexObject.getDate().toString().equals(object.getDate().toString()))
+ Assertions.fail("Date was " + object.getDate() + " expected " + complexObject.getDate());
+ // Probably incorrect - comparing Object[] arrays with Arrays.equals
+ if (!Arrays.equals(complexObject.getArray(), object.getArray())) Assertions.fail("Array");
+ // Probably incorrect - comparing Object[] arrays with Arrays.equals
+ if (!Arrays.equals(complexObject.getArrayOfObjectC(), object.getArrayOfObjectC()))
+ Assertions.fail("ArrayOfObjectC");
+ if (!complexObject.getLinkedList().equals(object.getLinkedList())) Assertions.fail("LinkedList");
+ if (!complexObject.getLinkedHashSet().equals(object.getLinkedHashSet())) Assertions.fail("LinkedHashSet");
+
+
+ }
+
+
+}
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/json/JsonEncoderTest.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/JsonEncoderTest.java
new file mode 100644
index 0000000..a32db3a
--- /dev/null
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/JsonEncoderTest.java
@@ -0,0 +1,247 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonDeserializer;
+import net.jonathangiles.tools.teenyhttpd.Pet;
+import net.jonathangiles.tools.teenyhttpd.ProductDto;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZonedDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class JsonEncoderTest {
+
+ @Test
+ void testSerialization() {
+ Person person = new Person()
+ .setName("Alex")
+ .setAge(25)
+ .setLikesIceCream(true)
+ .setToolList(List.of(new Tool("Mac", "Laptop"),
+ new Tool("IntelliJ", "IDE"),
+ new Tool("Coffee", "Fuel")
+ ))
+ .setBirthDate(LocalDate.of(1995, 1, 1))
+ .setFavoriteSongs(Set.of("Radio GaGa", "Whatever it takes", "Feel Good Inc"))
+ .setPet(new Pet()
+ .setName("Rocky")
+ .setType("Dog")
+ .setAge(1));
+
+ TeenyJson teenyJson = new TeenyJson();
+
+ long init = System.nanoTime();
+
+ String json = teenyJson.writeValueAsString(person);
+
+ System.out.println(json + " " + (System.nanoTime() - init) + " ns");
+
+ init = System.nanoTime();
+
+ json = teenyJson.writeValueAsString(person);
+
+ System.out.println(json + " " + (System.nanoTime() - init) + " ns");
+
+
+ Gson gson = new GsonBuilder()
+ .registerTypeAdapter(
+ LocalDateTime.class,
+ (JsonDeserializer) (j, type, jsonDeserializationContext) ->
+ ZonedDateTime.parse(j.getAsJsonPrimitive().getAsString()).toLocalDateTime()
+ )
+ .registerTypeAdapter(
+ LocalDate.class,
+ (JsonDeserializer) (localDate, type, jsonSerializationContext) ->
+ LocalDate.parse(localDate.getAsJsonPrimitive().getAsString()))
+ .create();
+
+
+ Person newPerson = gson.fromJson(json, Person.class);
+
+
+ if (!person.equals(newPerson)) {
+
+ System.out.println(person);
+ System.out.println(newPerson);
+
+ Assertions.fail("Objects are not equal");
+ }
+
+ init = System.nanoTime();
+
+ teenyJson.registerSerializer(String.class, (value) -> "\"hi\"");
+
+ json = teenyJson.writeValueAsString(person);
+
+ System.out.println(json + " " + (System.nanoTime() - init) + " ns");
+
+ newPerson = gson.fromJson(json, Person.class);
+
+ Assertions.assertEquals("hi", newPerson.getName());
+
+ }
+
+ @Test
+ void parseSimple() {
+
+ Pet pet = new Pet("Rocky", 1, "Dog");
+ String json = new TeenyJson().writeValueAsString(pet);
+
+ Pet parsedPet = new TeenyJson().readValue(json, Pet.class);
+
+ Assertions.assertEquals(pet, parsedPet);
+ }
+
+ @Test
+ void testSerializeProduct() {
+ String json = new TeenyJson().writeValueAsString(List.of(
+ new ProductDto(1, "MacBook", 2000),
+ new ProductDto(2, "iPhone", 1000)
+ .put("colors", Set.of("black", "blue", "pink"))
+ .put("warranty", Map.of("years", 2, "type", "full"))
+ .put("specs", List.of("A14", "5G", "FaceID"))
+ .put("chargerIncluded", true)
+ ));
+
+ System.out.println(json);
+ }
+
+ @Test
+ void testEncodeInteger() {
+ String json = new TeenyJson()
+ .writeValueAsString(Map.of("value", 1));
+
+ Assertions.assertEquals("{\"value\":1}", json);
+ }
+
+ @Test
+ void testEncodeString() {
+ String json = new TeenyJson()
+ .writeValueAsString(Map.of("value", "hello"));
+
+ Assertions.assertEquals("{\"value\":\"hello\"}", json);
+ }
+
+ @Test
+ void testEncodeBoolean() {
+ String json = new TeenyJson()
+ .writeValueAsString(Map.of("value", true));
+
+ Assertions.assertEquals("{\"value\":true}", json);
+
+ json = new TeenyJson()
+ .writeValueAsString(Map.of("value", false));
+
+ Assertions.assertEquals("{\"value\":false}", json);
+ }
+
+ @Test
+ void testEncodeNull() {
+ Map map = new HashMap<>();
+
+ map.put("value", null);
+
+ String json = new TeenyJson()
+ .writeValueAsString(map);
+
+ Assertions.assertEquals("{\"value\":null}", json);
+ }
+
+ @Test
+ void testEncodeArray() {
+ String json = new TeenyJson()
+ .writeValueAsString(Map.of("value", new int[]{1, 2, 3}));
+
+ Assertions.assertEquals("{\"value\":[1,2,3]}", json);
+ }
+
+ @Test
+ void testEncodeList() {
+ String json = new TeenyJson()
+ .writeValueAsString(Map.of("value", List.of(1, 2, 3)));
+
+ Assertions.assertEquals("{\"value\":[1,2,3]}", json);
+ }
+
+ @Test
+ void testEncodeMap() {
+ String json = new TeenyJson()
+ .writeValueAsString(Map.of("value", Map.of("key1", 1, "key2", 2)));
+
+ String case1 = "{\"value\":{\"key2\":2,\"key1\":1}}";
+ String case2 = "{\"value\":{\"key1\":1,\"key2\":2}}";
+
+ if (json.equals(case1) || json.equals(case2)) {
+ return;
+ }
+
+ Assertions.fail("Expected " + case1 + " or " + case2 + " but got " + json);
+ }
+
+ @Test
+ void testEncodeObject() {
+ String json = new TeenyJson()
+ .writeValueAsString(Map.of("value", new Person().setName("Alex").setAge(25)));
+
+ Assertions.assertEquals("{\"value\":{\"name\":\"Alex\",\"IceCreamLover\":false,\"age\":25}}", json);
+ }
+
+ @Test
+ void testJsonRaw() {
+ JsonRawObject target = new JsonRawObject()
+ .setName("Alex")
+ .setAge(30)
+ .setSalary(75)
+ .setDeveloper(true)
+ .setProgrammingLanguages(new TeenyJson()
+ .writeValueAsString(List.of("Java", "Kotlin", "JavaScript")));
+
+ String json = new TeenyJson().writeValueAsString(target);
+
+ System.out.println(json);
+
+ Map, ?> map = new TeenyJson()
+ .readValue(json, Map.class);
+
+ System.out.println(map);
+
+ Assertions.assertEquals("Alex", map.get("name"));
+ Assertions.assertEquals("30", map.get("age"));
+ Assertions.assertEquals("75.0", map.get("salary"));
+ Assertions.assertEquals(Boolean.TRUE, map.get("developer"));
+
+ List list = (List) map.get("programmingLanguages");
+
+ Assertions.assertEquals("Java", list.get(0));
+ Assertions.assertEquals("Kotlin", list.get(1));
+ Assertions.assertEquals("JavaScript", list.get(2));
+
+ }
+
+ @Test
+ void testJsonIncludeNonNull() {
+ JsonRawObject target = new JsonRawObject()
+ .setName("Alex")
+ .setAge(30)
+ .setSalary(75)
+ .setDeveloper(true);
+
+ String json = new TeenyJson().writeValueAsString(target);
+
+ System.out.println(json);
+
+ Map, ?> map = new TeenyJson()
+ .readValue(json, Map.class);
+
+ Assertions.assertNotNull(map.get("name"));
+ Assertions.assertNull(map.get("programmingLanguages"));
+ }
+
+}
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/json/JsonRawObject.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/JsonRawObject.java
new file mode 100644
index 0000000..56bd37a
--- /dev/null
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/JsonRawObject.java
@@ -0,0 +1,56 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+public class JsonRawObject {
+ private String name;
+ private int age;
+ private boolean isDeveloper;
+ private double salary;
+ private String programmingLanguages;
+
+ public String getName() {
+ return name;
+ }
+
+ public JsonRawObject setName(String name) {
+ this.name = name;
+ return this;
+ }
+
+ public int getAge() {
+ return age;
+ }
+
+ public JsonRawObject setAge(int age) {
+ this.age = age;
+ return this;
+ }
+
+ public boolean isDeveloper() {
+ return isDeveloper;
+ }
+
+ public JsonRawObject setDeveloper(boolean developer) {
+ isDeveloper = developer;
+ return this;
+ }
+
+ public double getSalary() {
+ return salary;
+ }
+
+ public JsonRawObject setSalary(double salary) {
+ this.salary = salary;
+ return this;
+ }
+
+ @JsonIncludeNonNull
+ @JsonRaw(includeKey = true)
+ public String getProgrammingLanguages() {
+ return programmingLanguages;
+ }
+
+ public JsonRawObject setProgrammingLanguages(String programmingLanguages) {
+ this.programmingLanguages = programmingLanguages;
+ return this;
+ }
+}
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/json/ObjectA.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/ObjectA.java
new file mode 100644
index 0000000..0cb57db
--- /dev/null
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/ObjectA.java
@@ -0,0 +1,74 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import java.util.List;
+
+public class ObjectA {
+
+ String name;
+ int age;
+ ObjectB objectB;
+ boolean developer;
+ List hobbies;
+
+ public ObjectA() {
+ }
+
+ public ObjectA(String name, int age, boolean developer, List hobbies, ObjectB objectB) {
+ this.name = name;
+ this.age = age;
+ this.objectB = objectB;
+ this.hobbies = hobbies;
+ this.developer = developer;
+ }
+
+ public List getHobbies() {
+ return hobbies;
+ }
+
+ public boolean isDeveloper() {
+ return developer;
+ }
+
+ public void setHobbies(List hobbies) {
+ this.hobbies = hobbies;
+ }
+
+ public void setDeveloper(boolean developer) {
+ this.developer = developer;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public int getAge() {
+ return age;
+ }
+
+ public void setAge(int age) {
+ this.age = age;
+ }
+
+ public ObjectB getObjectB() {
+ return objectB;
+ }
+
+ public void setObjectB(ObjectB objectB) {
+ this.objectB = objectB;
+ }
+
+ @Override
+ public String toString() {
+ return "ObjectA{" +
+ "name='" + name + '\'' +
+ ", age=" + age +
+ ", objectB=" + objectB +
+ ", developer=" + developer +
+ ", hobbies=" + hobbies +
+ '}';
+ }
+}
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/json/ObjectB.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/ObjectB.java
new file mode 100644
index 0000000..5bc3da2
--- /dev/null
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/ObjectB.java
@@ -0,0 +1,50 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+public class ObjectB {
+ String name;
+ int age;
+ ObjectC objectC;
+
+ public ObjectB() {
+ }
+
+ public ObjectB(String name, int age, ObjectC objectC) {
+ this.name = name;
+ this.age = age;
+ this.objectC = objectC;
+ }
+
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public int getAge() {
+ return age;
+ }
+
+ public void setAge(int age) {
+ this.age = age;
+ }
+
+ public ObjectC getObjectC() {
+ return objectC;
+ }
+
+ public void setObjectC(ObjectC objectC) {
+ this.objectC = objectC;
+ }
+
+ @Override
+ public String toString() {
+ return "ObjectB{" +
+ "name='" + name + '\'' +
+ ", age=" + age +
+ ", objectC=" + objectC +
+ '}';
+ }
+}
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/json/ObjectC.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/ObjectC.java
new file mode 100644
index 0000000..3c72e3a
--- /dev/null
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/ObjectC.java
@@ -0,0 +1,56 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+public class ObjectC implements C {
+ String name;
+ int age;
+
+ public ObjectC() {
+ }
+
+ public ObjectC(String name, int age) {
+ this.name = name;
+ this.age = age;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public int getAge() {
+ return age;
+ }
+
+ public void setAge(int age) {
+ this.age = age;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (this == object) return true;
+ if (object == null || getClass() != object.getClass()) return false;
+
+ ObjectC objectC = (ObjectC) object;
+
+ if (getAge() != objectC.getAge()) return false;
+ return getName().equals(objectC.getName());
+ }
+
+ @Override
+ public int hashCode() {
+ int result = getName().hashCode();
+ result = 31 * result + getAge();
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "ObjectC{" +
+ "name='" + name + '\'' +
+ ", age=" + age +
+ '}';
+ }
+}
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/json/ObjectD.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/ObjectD.java
new file mode 100644
index 0000000..313fc82
--- /dev/null
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/ObjectD.java
@@ -0,0 +1,48 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import java.util.List;
+
+public class ObjectD {
+
+ private String name;
+ private C c;
+ private List extends C> list;
+
+ public String getName() {
+ return name;
+ }
+
+ public ObjectD setName(String name) {
+ this.name = name;
+ return this;
+ }
+
+ public C getC() {
+ return c;
+ }
+
+ public List extends C> getList() {
+ return list;
+ }
+
+ @JsonDeserialize(contentAs = ObjectC.class)
+ public ObjectD setList(List extends C> list) {
+ this.list = list;
+ return this;
+ }
+
+ @JsonDeserialize(as = ObjectC.class)
+ public ObjectD setC(C c) {
+ this.c = c;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return "ObjectD{" +
+ "name='" + name + '\'' +
+ ", c=" + c +
+ ", list=" + list +
+ '}';
+ }
+}
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/json/Person.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/Person.java
new file mode 100644
index 0000000..d850e6d
--- /dev/null
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/Person.java
@@ -0,0 +1,144 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import com.google.gson.annotations.SerializedName;
+import net.jonathangiles.tools.teenyhttpd.Pet;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+@JsonIncludeNonNull
+public class Person {
+ private String name;
+ private int age;
+ private LocalDate birthDate;
+ private LocalDateTime lastUpdated;
+ @SerializedName("IceCreamLover")
+ private boolean likesIceCream;
+ private Pet pet;
+ @SerializedName("bestSongs")
+ private Set favoriteSongs;
+ private List toolList;
+
+ public List getToolList() {
+ return toolList;
+ }
+
+ public Person setToolList(List toolList) {
+ this.toolList = toolList;
+ return this;
+ }
+
+ public Person setFavoriteSongs(Set favoriteSongs) {
+ this.favoriteSongs = favoriteSongs;
+ return this;
+ }
+
+ @JsonAlias("bestSongs")
+ public Set getFavoriteSongs() {
+ return favoriteSongs;
+ }
+
+ public Person setPet(Pet pet) {
+ this.pet = pet;
+ return this;
+ }
+
+ public Pet getPet() {
+ return pet;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Person setName(String name) {
+ this.name = name;
+ return this;
+ }
+
+ public int getAge() {
+ return age;
+ }
+
+ public Person setAge(int age) {
+ this.age = age;
+ return this;
+ }
+
+ public LocalDate getBirthDate() {
+ return birthDate;
+ }
+
+ public Person setBirthDate(LocalDate birthDate) {
+ this.birthDate = birthDate;
+ return this;
+ }
+
+ public LocalDateTime getLastUpdated() {
+ return lastUpdated;
+ }
+
+ public Person setLastUpdated(LocalDateTime lastUpdated) {
+ this.lastUpdated = lastUpdated;
+ return this;
+ }
+
+ @JsonAlias("IceCreamLover")
+ public boolean isLikesIceCream() {
+ return likesIceCream;
+ }
+
+ public Person setLikesIceCream(boolean likesIceCream) {
+ this.likesIceCream = likesIceCream;
+ return this;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (this == object) return true;
+ if (object == null || getClass() != object.getClass()) return false;
+
+ Person person = (Person) object;
+
+ if (age != person.age) return false;
+ if (likesIceCream != person.likesIceCream) return false;
+ if (!Objects.equals(name, person.name)) return false;
+ if (!Objects.equals(birthDate, person.birthDate)) return false;
+ if (!Objects.equals(lastUpdated, person.lastUpdated))
+ return false;
+ if (!Objects.equals(pet, person.pet)) return false;
+ if (!Objects.equals(favoriteSongs, person.favoriteSongs))
+ return false;
+ return Objects.equals(toolList, person.toolList);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = name != null ? name.hashCode() : 0;
+ result = 31 * result + age;
+ result = 31 * result + (birthDate != null ? birthDate.hashCode() : 0);
+ result = 31 * result + (lastUpdated != null ? lastUpdated.hashCode() : 0);
+ result = 31 * result + (likesIceCream ? 1 : 0);
+ result = 31 * result + (pet != null ? pet.hashCode() : 0);
+ result = 31 * result + (favoriteSongs != null ? favoriteSongs.hashCode() : 0);
+ result = 31 * result + (toolList != null ? toolList.hashCode() : 0);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "Person{" +
+ "name='" + name + '\'' +
+ ", age=" + age +
+ ", birthDate=" + birthDate +
+ ", lastUpdated=" + lastUpdated +
+ ", likesIceCream=" + likesIceCream +
+ ", pet=" + pet +
+ ", favoriteSongs=" + favoriteSongs +
+ ", toolList=" + toolList +
+ '}';
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/net/jonathangiles/tools/teenyhttpd/json/Tool.java b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/Tool.java
new file mode 100644
index 0000000..c8efbc5
--- /dev/null
+++ b/src/test/java/net/jonathangiles/tools/teenyhttpd/json/Tool.java
@@ -0,0 +1,60 @@
+package net.jonathangiles.tools.teenyhttpd.json;
+
+import java.util.Objects;
+
+public class Tool {
+ private String name;
+ private String kind;
+
+ public Tool(String name, String kind) {
+ this.name = name;
+ this.kind = kind;
+ }
+
+ public Tool() {
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Tool setName(String name) {
+ this.name = name;
+ return this;
+ }
+
+ public String getKind() {
+ return kind;
+ }
+
+ public Tool setKind(String kind) {
+ this.kind = kind;
+ return this;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (this == object) return true;
+ if (object == null || getClass() != object.getClass()) return false;
+
+ Tool tool = (Tool) object;
+
+ if (!Objects.equals(name, tool.name)) return false;
+ return Objects.equals(kind, tool.kind);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = name != null ? name.hashCode() : 0;
+ result = 31 * result + (kind != null ? kind.hashCode() : 0);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "Tool{" +
+ "name='" + name + '\'' +
+ ", kind='" + kind + '\'' +
+ '}';
+ }
+}
\ No newline at end of file