diff --git a/CHANGELOG.md b/CHANGELOG.md index bd937fdf..fe617e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ will use `Uniqueness.NONE`. This class's documentation was also updated to provi Updated `AttributeDefinition.toString()` to print a JSON model of the object to be consistent with all other SCIM objects in the library. +Updated `DateTimeUtils.parse()` to support date strings without timestamps (e.g., "1970-01-01"). +The time values will always be set to the start of the day. + ## 6.0.0 - 2026-May-11 The UnboundID SCIM SDK has been updated to use version 3 of the Jackson library (this release ships with v3.1.3). This change aligns the SCIM SDK with HTTP libraries such as Spring Framework 7/Spring diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/DateTimeUtils.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/DateTimeUtils.java index 226fdf65..fcc670c1 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/DateTimeUtils.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/DateTimeUtils.java @@ -32,14 +32,18 @@ package com.unboundid.scim2.common.utils; import com.unboundid.scim2.common.annotations.NotNull; -import java.time.DateTimeException; +import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAccessor; +import java.time.temporal.TemporalQueries; import java.util.Calendar; import java.util.Date; +import java.util.Objects; import java.util.TimeZone; +import static java.time.format.DateTimeFormatter.ISO_DATE; import static java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME; /** @@ -47,6 +51,16 @@ * type is defined as a valid {@code xsd:dateTime} in * * RFC 7643, section 2.3.5. + *

+ * + * This class is primarily used during JSON deserialization of date-time values + * within this SDK. However, it may be used as a general utility class to + * perform conversions between date-time strings (e.g., "1970-01-01T00:00:00Z") + * and Java objects. The primary entry points to this class are: + * */ public final class DateTimeUtils { @@ -135,23 +149,13 @@ public static String format(@NotNull final Calendar calendar) * @param dateStr A SCIM 2 DateTime string. * @return The DateTime string as a Calendar value. * @throws IllegalArgumentException if the string cannot be parsed as an - * xsd:dateTime value. + * {@code xsd:dateTime} value. */ @NotNull public static Calendar parse(@NotNull final String dateStr) throws IllegalArgumentException { - OffsetDateTime parsedTime; - try - { - parsedTime = OffsetDateTime.parse(dateStr); - } - catch (DateTimeException e) - { - // Re-throw as an IllegalArgumentException for backward compatibility with - // previous releases of the SCIM SDK. - throw new IllegalArgumentException(e); - } + OffsetDateTime parsedTime = asOffsetDateTime(dateStr); // In previous releases, the default region was "GMT+00:00". ZoneOffset offset = parsedTime.getOffset(); @@ -159,7 +163,7 @@ public static Calendar parse(@NotNull final String dateStr) ? GMT : TimeZone.getTimeZone(offset); Calendar calendar = Calendar.getInstance(zone); - calendar.setTime(Date.from(parsedTime.toInstant())); + calendar.setTimeInMillis(parsedTime.toInstant().toEpochMilli()); return calendar; } @@ -180,4 +184,38 @@ public static Calendar parse(final long timestampMillis) c.setTimeInMillis(timestampMillis); return c; } + + /** + * Converts the provided string timestamp into a Java object. + */ + @NotNull + private static OffsetDateTime asOffsetDateTime(@NotNull final String dateStr) + throws IllegalArgumentException + { + Exception original; + try + { + // Parse timestamps of the form: 1970-01-01T00:00:00Z + return OffsetDateTime.parse(dateStr); + } + catch (Exception e) + { + original = e; + } + + try + { + // Fall back to a date parser without a time: 1970-01-01 + TemporalAccessor parsedDate = ISO_DATE.parse(dateStr); + ZoneOffset offset = Objects.requireNonNullElse( + parsedDate.query(TemporalQueries.offset()), ZoneOffset.UTC); + + return LocalDate.from(parsedDate).atStartOfDay().atOffset(offset); + } + catch (Exception e) + { + // Use the original exception, as it is more likely to have relevant info. + throw new IllegalArgumentException(original); + } + } } diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/DateTimeUtilsTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/DateTimeUtilsTest.java index 8f2b6ece..e4f046f4 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/DateTimeUtilsTest.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/DateTimeUtilsTest.java @@ -56,8 +56,7 @@ */ public class DateTimeUtilsTest { - // In previous releases, this was "GMT+00:00". Now it represents UTC. - private static final TimeZone GMT = TimeZone.getTimeZone(ZoneOffset.UTC); + private static final TimeZone UTC = TimeZone.getTimeZone(ZoneOffset.UTC); /** * Reset the value of the system property. @@ -119,9 +118,9 @@ public void testConversionToString(final String expected, final TimeZone timezone) { // Serialize the object timestamp to a string. The result should match the - // provided string representation. + // provided string representation in uppercase. String computedTimestamp = DateTimeUtils.format(dateObject, timezone); - assertThat(computedTimestamp).isEqualTo(expected); + assertThat(computedTimestamp).isEqualTo(expected.toUpperCase()); } /** @@ -157,9 +156,27 @@ public void testInvalidTimestamps(final String invalidString) { assertThatThrownBy(() -> DateTimeUtils.parse(invalidString)) .isInstanceOf(IllegalArgumentException.class) + .hasMessageFindingMatch("Invalid value for \\S+ \\(valid values \\d+ - \\d+") .hasMessageContaining("could not be parsed"); } + /** + * Ensure a date string can be parsed. + * + * @param dateString The timestamp from the data provider. + * @param timestamp The expected UNIX timestamp value. + * @param timezone The expected timezone value. + */ + @Test(dataProvider = "datesWithoutTimestamps") + public void testDatesWithoutTimestamps(final String dateString, + final long timestamp, + final TimeZone timezone) + { + Calendar deserialized = DateTimeUtils.parse(dateString); + assertThat(deserialized.getTimeInMillis()).isEqualTo(timestamp); + assertThat(deserialized.getTimeZone()).isEqualTo(timezone); + } + /** * Validate the {@link DateTimeUtils#format(Date)} method. This test will only * be run if the timezone value is UTC. @@ -176,7 +193,7 @@ public void testUTC(final String expected, if (timezone.getRawOffset() == 0) { String computedTimestamp = DateTimeUtils.format(dateObject); - assertThat(computedTimestamp).isEqualTo(expected); + assertThat(computedTimestamp).isEqualTo(expected.toUpperCase()); } } @@ -245,6 +262,38 @@ public void testDeserialization(final String ignored, assertThat(createdTimestamp.getTimeZone().getRawOffset()).isEqualTo(0); } + /** + * Ensure date strings without timezones can be deserialized. + * + * @param dateString The date string value without a timestamp. + * @param unixTimestamp The UNIX timestamp equivalent to the date string. + * @param timezone The time zone of the date string. + */ + @Test(dataProvider = "datesWithoutTimestamps") + public void testDateOnlyDeserialization(final String dateString, + final long unixTimestamp, + final TimeZone timezone) + { + String json = """ + { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], + "userName": "name", + "meta": { + "created": "%s" + } + }""".formatted(dateString); + + // Deserialize the JSON into a user resource. + UserResource user = JsonUtils.getObjectReader().forType(UserResource.class) + .readValue(json); + + // Ensure the value was properly deserialized. + Calendar expected = Calendar.getInstance(timezone); + expected.setTimeInMillis(unixTimestamp); + assertThat(user.getMeta()).isNotNull(); + assertThat(user.getMeta().getCreated()).isEqualTo(expected); + } + /** * Ensures an exception is thrown when a malformed timestamp is deserialized. */ @@ -311,8 +360,6 @@ public Object[][] invalidTimestamps() { return new Object[][] { - new String[] { "1989-03-14T17:56:47+09:999" }, - new String[] { "1989-03-14T17:56:47+99:00" }, new String[] { "1989-03-14T17:56:61+02:00" }, new String[] { "1989-03-14T17:61:47+02:00" }, new String[] { "1989-03-14T25:56:47+02:00" }, @@ -321,6 +368,31 @@ public Object[][] invalidTimestamps() }; } + /** + * A data provider representing string timestamps that only contain dates. + * + * @return A set of date strings. + */ + @DataProvider(name = "datesWithoutTimestamps") + public Object[][] datesWithoutTimestamps() + { + return new Object[][] + { + { "1970-01-01", 0L, UTC }, + { "1970-01-01Z", 0L, UTC }, + { "1970-01-01+05:00", -18000000L, zone(5) }, + { "1970-01-01-05:00", 18000000L, zone(-5) }, + { "2079-10-18", 3464812800000L, UTC }, + { "2098-06-17", 4053801600000L, UTC }, + { "2076-12-31", 3376598400000L, UTC }, + { "2021-04-30", 1619740800000L, UTC }, + { "1974-04-09", 134697600000L, UTC }, + { "2061-12-11", 2901484800000L, UTC }, + { "1955-01-22", -471571200000L, UTC }, + { "1955-01-22+09:00", -471603600000L, zone(9) }, + }; + } + /** * A data provider for test cases that each describe: *