GsonNotificationReader.java

package com.reallifedeveloper.common.infrastructure;

import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Optional;

import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;

import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;

import com.reallifedeveloper.common.application.notification.NotificationReader;
import com.reallifedeveloper.common.domain.ErrorHandling;

/**
 * An implementation of the {@link NotificationReader} interface that works with JSON as the serialized form, using
 * <a href="https://code.google.com/p/google-gson/">Gson</a> to parse the JSON string.
 *
 * @author RealLifeDeveloper
 */
@SuppressWarnings("PMD.UnnecessaryCast") // We use casts to @NonNull that PMD considers unnecessary.
public final class GsonNotificationReader implements NotificationReader {

    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(GsonObjectSerializer.DATE_TIME_FORMAT);

    private final JsonObject notification;
    private final JsonObject event;

    /**
     * Creates a new {@code GsonNotificationReader} that parses the given JSON-serialized notification.
     *
     * @param jsonNotification the JSON representation of the notification to read
     *
     * @throws IllegalArgumentException if {@code jsonNotification} is {@code null} or not a valid JSON object
     */
    public GsonNotificationReader(String jsonNotification) {
        ErrorHandling.checkNull("jsonNotification must not be null", jsonNotification);
        try {
            JsonElement element = JsonParser.parseString(jsonNotification);
            if (!element.isJsonObject()) {
                throw new IllegalArgumentException("Not a JSON object: " + jsonNotification);
            }
            this.notification = element.getAsJsonObject();
        } catch (JsonParseException e) {
            throw new IllegalArgumentException("Not legal JSON: " + jsonNotification, e);
        }
        if (JsonUtil.isNull(notification.get("event"))) {
            throw new IllegalArgumentException("event not found in JSON string: " + jsonNotification);
        }
        this.event = notification.get("event").getAsJsonObject();
    }

    @Override
    public String eventType() {
        return (@NonNull String) JsonUtil.stringValue(notification, "eventType", true);
    }

    @Override
    public Long storedEventId() {
        return (@NonNull Long) JsonUtil.longValue(notification, "storedEventId", true);
    }

    @Override
    public ZonedDateTime occurredOn() {
        return (@NonNull ZonedDateTime) JsonUtil.zonedDateTimeValue(notification, "occurredOn", true);
    }

    @Override
    public Integer eventVersion() {
        return (@NonNull Integer) JsonUtil.intValue(event, "eventVersion", true);
    }

    @Override
    public Optional<Integer> eventIntValue(String fieldName) {
        return Optional.ofNullable(JsonUtil.intValue(event, fieldName, false));
    }

    @Override
    public Optional<Long> eventLongValue(String fieldName) {
        return Optional.ofNullable(JsonUtil.longValue(event, fieldName, false));
    }

    @Override
    public Optional<Double> eventDoubleValue(String fieldName) {
        return Optional.ofNullable(JsonUtil.doubleValue(event, fieldName, false));
    }

    @Override
    public Optional<String> eventStringValue(String fieldName) {
        return Optional.ofNullable(JsonUtil.stringValue(event, fieldName, false));
    }

    @Override
    public Optional<ZonedDateTime> zonedDateTimeValue(String fieldName) {
        return Optional.ofNullable(JsonUtil.zonedDateTimeValue(event, fieldName, false));
    }

    private static final class JsonUtil {

        private static @Nullable Integer intValue(JsonObject object, String fieldName, boolean required) {
            JsonElement jsonElement = fieldValue(object, fieldName, required);
            return isNull(jsonElement) ? null : jsonElement.getAsInt();
        }

        private static @Nullable Long longValue(JsonObject object, String fieldName, boolean required) {
            JsonElement jsonElement = fieldValue(object, fieldName, required);
            return isNull(jsonElement) ? null : jsonElement.getAsLong();
        }

        private static @Nullable Double doubleValue(JsonObject object, String fieldName, boolean required) {
            JsonElement jsonElement = fieldValue(object, fieldName, required);
            return isNull(jsonElement) ? null : jsonElement.getAsDouble();
        }

        private static @Nullable String stringValue(JsonObject object, String fieldName, boolean required) {
            JsonElement jsonElement = fieldValue(object, fieldName, required);
            return isNull(jsonElement) ? null : jsonElement.getAsString();
        }

        private static @Nullable ZonedDateTime zonedDateTimeValue(JsonObject object, String fieldName, boolean required) {
            JsonElement jsonElement = fieldValue(object, fieldName, required);
            return isNull(jsonElement) ? null : ZonedDateTime.parse(jsonElement.getAsString(), DATE_TIME_FORMATTER);
        }

        /**
         * Gives the value of a, potentially nested, field in a {@code JsonObject}. The field name can be simple, e.g., "foo", or nested,
         * e.g., "foo.bar". If the name is nested, the sub-components should be the names of nested objects.
         * <p>
         * For example, if {@code fieldName} is "foo.bar.baz", the object "foo" is first looked up in {@code object}, then the object "bar"
         * is looked up in the result, and finally the value of the field "baz" in the resulting object is returned.
         * <p>
         * If {@code required} is {@code true}, this method never returns {@code null}.
         *
         * @param rootObject the {@code JsonObject} to use to look up the value of the field
         * @param fieldName  the name of the field to look up, potentially nested, e.g., "foo.bar"
         * @param required   if {@code true}, throws an exception if the field does not exist or is {@code null}
         *
         * @return the value of the, potentially nested, field as a {@code JsonElement}
         *
         * @throws IllegalArgumentException if {@code required} is {@code true} and the field does not exist or is {@code null}
         */
        private static JsonElement fieldValue(JsonObject rootObject, String fieldName, boolean required) {
            JsonObject object = rootObject;
            String[] fieldNames = fieldName.split("\\.");
            for (int i = 0; i < fieldNames.length - 1; i++) {
                JsonElement element = object.get(fieldNames[i]);
                if (element == null) {
                    throw new IllegalArgumentException("Field " + fieldName + " not found: object=" + rootObject);
                }
                if (!element.isJsonObject()) {
                    throw new IllegalArgumentException("Field " + fieldName + " not an object: object=" + rootObject);
                }
                object = element.getAsJsonObject();
            }
            JsonElement jsonElement = object.get(fieldNames[fieldNames.length - 1]);
            if (required && isNull(jsonElement)) {
                throw new IllegalArgumentException("Field " + fieldName + " is missing or null: object=" + rootObject);
            }
            return jsonElement;
        }

        private static boolean isNull(JsonElement jsonElement) {
            return jsonElement == null || jsonElement == JsonNull.INSTANCE;
        }
    }
}