GoogleTypesUtil.java

package net.morimekta.proto.utils;

import com.google.protobuf.Timestamp;
import net.morimekta.strings.Displayable;

import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static java.lang.Integer.parseInt;
import static java.lang.Long.parseLong;
import static java.time.Duration.ofDays;
import static java.time.Duration.ofHours;
import static java.time.Duration.ofMillis;
import static java.time.Duration.ofMinutes;
import static java.time.Duration.ofNanos;
import static java.time.Duration.ofSeconds;
import static java.util.Objects.requireNonNull;
import static java.util.concurrent.TimeUnit.SECONDS;
import static net.morimekta.proto.utils.ValueUtil.asString;
import static net.morimekta.proto.utils.ValueUtil.isNullOrDefault;

/**
 * Utility for converting between core google types and the
 * most used java equivalents.
 * <p>
 * {@link Timestamp} are converted with {@link Instant}, which is functionally
 * equivalent. It is always assumed the Timestamp is in UTC, so no zone conversion
 * will be done.
 * <p>
 * {@link com.google.protobuf.Duration} are converted with {@link Duration}, which
 * is functionally equivalent.
 */
public final class GoogleTypesUtil {
    // ---- Timestamp ----

    /**
     * @param ts A google type Timestamp or null.
     * @return The java timestamp of milliseconds since epoch.
     */
    public static long toEpochMillis(Timestamp ts) {
        return toInstant(ts).toEpochMilli();
    }

    /**
     * @param ts A google type Timestamp or null.
     * @return The unix timestamp of seconds since epoch.
     */
    public static long toEpochSeconds(Timestamp ts) {
        if (ts == null) {
            return 0;
        }
        return ts.getSeconds();
    }

    /**
     * @param ts A google type timestamp or null.
     * @return The instant of the timestamp, or null if null or default input.
     */
    public static Instant toInstantOrNull(Timestamp ts) {
        if (isNullOrDefault(ts)) {
            return null;
        }
        return Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos());
    }

    /**
     * @param ts A google type timestamp or null.
     * @return The instant of the timestamp, or epoch if null or default input.
     */
    public static Instant toInstant(Timestamp ts) {
        if (ts == null) {
            return Instant.EPOCH;
        }
        return Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos());
    }

    /**
     * @param instant A java instant.
     * @return The google type Timestamp, or default value if null input.
     */
    public static Timestamp toProtoTimestamp(Instant instant) {
        if (instant == null) {
            return Timestamp.getDefaultInstance();
        }
        return Timestamp.newBuilder()
                        .setSeconds(instant.getEpochSecond())
                        .setNanos(instant.getNano())
                        .build();
    }

    /**
     * Make a proto timestamp from a time value.
     *
     * @param ts   The timestamp.
     * @param unit The unit of the timestamp.
     * @return The google type Timestamp.
     */
    public static Timestamp makeProtoTimestamp(long ts, TimeUnit unit) {
        requireNonNull(unit, "unit == null");
        if (ts == 0) {
            return Timestamp.getDefaultInstance();
        }
        var seconds = unit.toSeconds(ts);
        var remainder = ts - unit.convert(seconds, SECONDS);
        var nanos = (int) unit.toNanos(remainder);
        return Timestamp.newBuilder()
                        .setSeconds(seconds)
                        .setNanos(nanos)
                        .build();
    }

    /**
     * @param ts         A google type Timestamp.
     * @param resolution Time resolution to truncate timestamp to.
     * @return The timestamp truncated to given unit.
     */
    public static Timestamp truncateTimestamp(Timestamp ts, ChronoUnit resolution) {
        return toProtoTimestamp(toInstant(ts).truncatedTo(resolution));
    }

    /**
     * @param ts A google type Timestamp.
     * @return The ISO date time string of the instant for the timestamp.
     */
    public static String displayableTimestamp(Timestamp ts) {
        return Displayable.displayableInstant(toInstantOrNull(ts));
    }

    /**
     * @param value ISO formatted timestamp string.
     * @return Parsed timestamp as google timestamp.
     */
    public static Timestamp parseTimestampString(String value) {
        return toProtoTimestamp(parseJavaTimestampString(value));
    }

    /**
     * @param value ISO formatted timestamp string.
     * @return Parsed timestamp as java instant.
     */
    public static Instant parseJavaTimestampString(String value) {
        Matcher isoDateTime = ISO_DATE_TIME.matcher(value);
        if (!isoDateTime.matches()) {
            throw new IllegalArgumentException("Expected ISO date format, but found: " + asString(value));
        }
        var year = parseInt(isoDateTime.group("year"));
        var month = parseInt(isoDateTime.group("month"));
        var day = parseInt(isoDateTime.group("day"));
        if (isoDateTime.group("hour") == null) {
            return LocalDate.of(year, month, day).atStartOfDay().toInstant(ZoneOffset.UTC);
        }
        var hour = parseInt(isoDateTime.group("hour"));
        var minute = parseInt(isoDateTime.group("minute"));
        var second = parseInt(isoDateTime.group("second"));
        var dateTime = LocalDateTime.of(year, month, day, hour, minute, second);
        var fractionsStr = isoDateTime.group("fractionSeconds");
        if (fractionsStr != null) {
            var fractionsInt = parseInt(fractionsStr);
            if (fractionsInt > 0) {
                for (int i = fractionsStr.length(); i < 9; ++i) {
                    fractionsInt *= 10;
                }
                dateTime = dateTime.plus(fractionsInt, ChronoUnit.NANOS);
            }
        }
        return dateTime.toInstant(ZoneOffset.UTC);
    }

    // ---- Duration ----

    /**
     * @param protoDuration A proto duration instance or null.
     * @return A java duration instance.
     */
    public static Duration toJavaDuration(com.google.protobuf.Duration protoDuration) {
        if (protoDuration == null) {
            return Duration.ZERO;
        }
        return ofSeconds(protoDuration.getSeconds(), protoDuration.getNanos());
    }

    /**
     * @param protoDuration A proto duration instance or null.
     * @return A java duration instant, or null if input was null or default instance.
     */
    public static Duration toJavaDurationOrNull(com.google.protobuf.Duration protoDuration) {
        if (protoDuration == null || protoDuration.equals(com.google.protobuf.Duration.getDefaultInstance())) {
            return null;
        }
        return ofSeconds(protoDuration.getSeconds(), protoDuration.getNanos());
    }

    /**
     * @param duration A java duration or null.
     * @return Proto duration matching input or default if null input.
     */
    public static com.google.protobuf.Duration toProtoDuration(Duration duration) {
        if (duration == null || duration.isZero()) {
            return com.google.protobuf.Duration.getDefaultInstance();
        }
        return com.google.protobuf.Duration.newBuilder()
                                           .setSeconds(duration.getSeconds())
                                           .setNanos(duration.getNano())
                                           .build();
    }

    /**
     * @param duration Duration units.
     * @param unit     Duration unit.
     * @return The combined duration for the input.
     */
    public static com.google.protobuf.Duration makeProtoDuration(long duration, TimeUnit unit) {
        var sign = duration < 0 ? -1 : 1;
        var seconds = unit.toSeconds(Math.abs(duration));
        var remainder = Math.abs(duration) - unit.convert(seconds, SECONDS);
        var nanos = (int) unit.toNanos(remainder);
        if (sign < 0 && nanos > 0) {
            ++seconds;
            nanos = NANOS_IN_SECOND - nanos;
        }
        return com.google.protobuf.Duration.newBuilder()
                                           .setSeconds(sign * seconds)
                                           .setNanos(nanos)
                                           .build();
    }

    /**
     * @param value A simple duration string.
     * @return Parsed proto duration from the string value.
     */
    public static com.google.protobuf.Duration parseDurationString(String value) {
        return toProtoDuration(parseJavaDurationString(value));
    }

    /**
     * @param value A simple duration string.
     * @return Parsed java duration from the string value.
     */
    public static Duration parseJavaDurationString(String value) {
        Matcher duration = DURATION_COMPLEX.matcher(value);
        if (duration.matches()) {
            var out = Duration.ZERO;
            out = updatedDuration(out, duration.group("weeks"), s -> ofDays(7L * parseInt(s)));
            out = updatedDuration(out, duration.group("days"), s -> ofDays(parseInt(s)));
            out = updatedDuration(out, duration.group("hours"), s -> ofHours(parseInt(s)));
            out = updatedDuration(out, duration.group("minutes"), s -> ofMinutes(parseInt(s)));
            out = updatedDuration(out, duration.group("seconds"), s -> {
                var parts = s.split("\\.");
                if (parts.length == 2) {
                    var nanos = parts[1] + "0".repeat(9 - parts[1].length());
                    return ofSeconds(parseLong(parts[0]), parseInt(nanos));
                }
                return ofSeconds(parseLong(s));
            });
            out = updatedDuration(out, duration.group("millis"), s -> ofMillis(parseInt(s)));
            out = updatedDuration(out, duration.group("nanos"), s -> ofNanos(parseInt(s)));
            if (duration.group("minus") != null) {
                return out.negated();
            }
            return out;
        }
        throw new IllegalArgumentException("Expected duration string, but found: " + asString(value));
    }

    /**
     * Make a simple duration string from a proto duration instance.
     *
     * @param duration The duration instance.
     * @return The simple duration string.
     */
    public static String simpleDurationString(com.google.protobuf.Duration duration) {
        if (duration == null) {
            return null;
        }
        return simpleDurationString(toJavaDuration(duration));
    }

    /**
     * Make a simple duration string from a proto duration instance.
     *
     * @param duration The duration instance.
     * @return The simple duration string.
     */
    public static String simpleDurationString(Duration duration) {
        if (duration == null) {
            return null;
        }
        if (duration.isNegative()) {
            return "-" + simpleDurationString(duration.abs());
        } else {
            if (duration.getNano() != 0) {
                var nanos = String.format("%09d", duration.getNano());
                var i = nanos.length();
                while (i > 0 && nanos.charAt(i - 1) == '0') {
                    --i;
                }
                return duration.getSeconds() + "." + nanos.substring(0, i) + "s";
            }
            return duration.getSeconds() + "s";
        }
    }

    /**
     * @param duration A proto duration.
     * @return A full duration string from the proto duration.
     */
    public static String displayableDuration(com.google.protobuf.Duration duration) {
        return Displayable.displayableDuration(toJavaDurationOrNull(duration));
    }

    // --- Private ---

    private static Duration updatedDuration(Duration orig, String amount, Function<String, Duration> parser) {
        if (amount == null) {
            return orig;
        } else {
            return orig.plus(parser.apply(amount));
        }
    }

    private static final Pattern DURATION_COMPLEX = Pattern.compile(
            "(?<minus>-)?" +
            "(?:(?<weeks>\\d+) ?(?:w|weeks?) ?)?" +
            "(?:(?<days>\\d+) ?(?:d|days?) ?)?" +
            "(?:(?<hours>\\d+) ?(?:h|hours?) ?)?" +
            "(?:(?<minutes>\\d+) ?(?:m|min|minutes?) ?)?" +
            "(?:(?<seconds>\\d+(?:\\.\\d{1,9})?) ?(?:s|sec|seconds?) ?)?" +
            "(?:(?<millis>\\d+) ?(?:ms|millis?) ?)?" +
            "(?:(?<nanos>\\d+) ?(?:ns|nanos?))?");
    private static final Pattern ISO_DATE_TIME    = Pattern.compile(
            "^(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})" +
            "(?:[ tT](?<hour>\\d{2}):(?<minute>\\d{2}):(?<second>\\d{2})" +
            "(?:\\.(?<fractionSeconds>\\d{1,9}))?" +
            "(?: ?(Z|UTC|[-+]00:?00))?)?$");
    private static final int     NANOS_IN_SECOND  = (int) SECONDS.toNanos(1);

    private GoogleTypesUtil() {
    }
}