JodaDateDeserializerBase.java

package tools.jackson.datatype.joda.deser;

import java.util.TimeZone;

import org.joda.time.DateTimeField;
import org.joda.time.DateTimeFieldType;
import org.joda.time.DateTimeZone;
import org.joda.time.IllegalFieldValueException;
import org.joda.time.chrono.ISOChronology;
import org.joda.time.field.FieldUtils;

import com.fasterxml.jackson.annotation.JsonFormat;

import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonToken;
import tools.jackson.databind.*;
import tools.jackson.databind.util.ClassUtil;
import tools.jackson.datatype.joda.cfg.JacksonJodaDateFormat;

/**
 * Intermediate base class used by deserializers that allow configuration
 * via <code>JsonFormat</code> annotation
 */
public abstract class JodaDateDeserializerBase<T>
    extends JodaDeserializerBase<T>
{
    /**
     * Timestamps strictly within {@code (-2^62, 2^62)} (about +/- 146 million
     * years) are always within supported year range and cannot overflow
     * when time zone offset is added; ones outside need to be verified.
     */
    private final static long SAFE_TIMESTAMP_RANGE = 1L << 62;

    protected final JacksonJodaDateFormat _format;

    /**
     * Cached conversion of the last seen context {@link TimeZone} into
     * {@link DateTimeZone}, to avoid re-resolving it for every value.
     */
    private transient volatile ContextZone _contextZone;

    protected JodaDateDeserializerBase(Class<?> type, JacksonJodaDateFormat format)
    {
        super(type);
        _format = format;
    }

    public abstract JodaDateDeserializerBase<?> withFormat(JacksonJodaDateFormat format);

    @Override
    public ValueDeserializer<?> createContextual(DeserializationContext ctxt,
            BeanProperty prop)
    {
        JsonFormat.Value ann = findFormatOverrides(ctxt, prop, handledType());
        if (ann != null) {
            JacksonJodaDateFormat format = _format;
            Boolean useTimestamp;

            // Simple case first: serialize as numeric timestamp?
            if (ann.getShape().isNumeric()) {
                useTimestamp = Boolean.TRUE;
            } else if (ann.getShape() == JsonFormat.Shape.STRING) {
                useTimestamp = Boolean.FALSE;
            } else if (ann.getShape() == JsonFormat.Shape.ARRAY) {
                // 17-Nov-2014, tatu: also, arrays typically contain non-string representation
                useTimestamp = Boolean.TRUE;
            } else  {
                useTimestamp = null;
            }
            // must not call if flag defined, to rely on defaults:
            if (useTimestamp != null) {
                format = format.withUseTimestamp(useTimestamp);
            }
            // for others, safe to call, null/empty just ignored
            format = format.with(ann);
            if (format != _format) {
                return withFormat(format);
            }
        }
        return this;
    }

    /**
     * Helper method for resolving the time zone to use for converting a
     * timestamp: explicit format time zone if one is specified; context
     * time zone if adjustment to it is enabled; UTC otherwise.
     */
    protected DateTimeZone _timestampZone(DeserializationContext ctxt) {
        if (_format.isTimezoneExplicit()) {
            return _format.getTimeZone();
        }
        if (_format.shouldAdjustToContextTimeZone(ctxt)) {
            return _contextTimeZone(ctxt);
        }
        // No adjustment allowed: timestamp is resolved as-is (UTC)
        return DateTimeZone.UTC;
    }

    /**
     * Helper method for converting a timestamp into a value using time zone
     * from {@link #_timestampZone}: reports values Joda cannot convert
     * (like edge-of-{@code long} timestamps for which zone offset addition
     * overflows, or that fall outside of supported year range) as
     * {@link DatabindException}.
     *
     * @param text Original textual representation of timestamp, if it was
     *   read from a String value; {@code null} if read from a number
     *
     * @since 3.3
     */
    @SuppressWarnings("unchecked")
    protected T _fromTimestamp(DeserializationContext ctxt, long ts, String text,
            TimestampConverter<T> converter)
    {
        try {
            final DateTimeZone zone = _timestampZone(ctxt);
            // Joda does not verify year range of local instant (and may silently
            // wrap around for edge-of-long values), so need to check explicitly;
            // but only for edge-of-long values (others are well within range)
            if (ts <= -SAFE_TIMESTAMP_RANGE || ts >= SAFE_TIMESTAMP_RANGE) {
                // NOTE: zone offset addition may also overflow (ArithmeticException)
                final long local = zone.convertUTCToLocal(ts);
                if (_verifyTimestampYear()) {
                    _verifyYear(ISOChronology.getInstanceUTC().year().get(local));
                }
            }
            return converter.convert(ts, zone);
        } catch (IllegalArgumentException | ArithmeticException e) {
            if (text != null) {
                return (T) _handleWeirdStringValue(ctxt, e, text,
                        "%s", e.getMessage());
            }
            return (T) _handleWeirdNumberValue(ctxt, e, ts,
                    "%s", e.getMessage());
        }
    }

    /**
     * Whether local instant of edge-of-{@code long} timestamps needs to be
     * verified to be within supported year range: needed for types that have
     * year component (Joda may silently produce invalid value otherwise).
     *
     * @since 3.3
     */
    protected boolean _verifyTimestampYear() {
        return true;
    }

    /**
     * Helper method for verifying that given year is within supported year
     * range: Joda does not verify it when parsing String values or constructing
     * local date (time) values, and may silently produce a different value
     * (wrapping around to the other end of the range) for years outside.
     *
     * @throws IllegalFieldValueException if year is outside of supported range
     *
     * @since 3.3
     */
    protected static void _verifyYear(int year) {
        final DateTimeField yearField = ISOChronology.getInstanceUTC().year();
        FieldUtils.verifyValueBounds(DateTimeFieldType.year(), year,
                yearField.getMinimumValue(), yearField.getMaximumValue());
    }

    /**
     * Helper method for reading the next required int element of a JSON Array
     * value: reports premature end of the Array using given message, and
     * otherwise handles the element (including coercion of Strings, floating-point
     * numbers and {@code null}s) using standard {@code int} handling.
     *
     * @since 3.3
     */
    protected int _nextArrayInt(JsonParser p, DeserializationContext ctxt,
            String endOfArrayMsg)
        throws JacksonException
    {
        int value = p.nextIntValue(-1); // fast speculative case
        if (value == -1) { // either -1, or not an integral number; slow path
            if (p.currentToken() == JsonToken.END_ARRAY) {
                throw ctxt.wrongTokenException(p, getValueType(ctxt), JsonToken.VALUE_NUMBER_INT,
                        endOfArrayMsg);
            }
            value = _parseIntPrimitive(p, ctxt);
        }
        return value;
    }

    /**
     * Overridden to report {@code null} (or empty String coerced to {@code null})
     * JSON Array element (the only case where {@code int} values are read by
     * date deserializers) with a more accurate message.
     */
    @Override
    protected Object _verifyNullForPrimitive(DeserializationContext ctxt,
            JsonParser p, Object defaultValue)
        throws DatabindException
    {
        if (ctxt.isEnabled(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)) {
            final String inputDesc = p.hasToken(JsonToken.VALUE_NULL)
                    ? "`null`" : "empty String (\"\")";
            return ctxt.handleNullForPrimitives(handledType(), p, this,
"Cannot coerce %s to `int` for element #%d of %s Array value (disable `DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES` to allow)",
                    inputDesc, p.streamReadContext().getCurrentIndex() + 1,
                    ClassUtil.nameOf(handledType()));
        }
        return defaultValue;
    }

    /**
     * Helper method for reporting a JSON Array value with out-of-range
     * component(s) (like month 13) via {@link tools.jackson.databind.deser.DeserializationProblemHandler}s,
     * failing with {@link DatabindException} if none handles it.
     *
     * @since 3.3
     */
    @SuppressWarnings("unchecked")
    protected T _handleInvalidArrayValue(DeserializationContext ctxt,
            IllegalFieldValueException e)
        throws JacksonException
    {
        return (T) _handleWeirdNumberValue(ctxt, e, e.getIllegalNumberValue(),
                "%s", e.getMessage());
    }

    /**
     * Helper method for accessing the context time zone as {@link DateTimeZone}.
     */
    protected DateTimeZone _contextTimeZone(DeserializationContext ctxt) {
        final TimeZone jdkZone = ctxt.getTimeZone();
        ContextZone cached = _contextZone;
        if (cached == null || cached.jdkZone() != jdkZone) {
            cached = new ContextZone(jdkZone, DateTimeZone.forTimeZone(jdkZone));
            _contextZone = cached;
        }
        return cached.jodaZone();
    }

    /**
     * Function for constructing a value from timestamp and time zone.
     *
     * @since 3.3
     */
    @FunctionalInterface
    protected interface TimestampConverter<T> {
        T convert(long timestamp, DateTimeZone zone);
    }

    private record ContextZone(TimeZone jdkZone, DateTimeZone jodaZone) { }
}