diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/DateUtils.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/DateUtils.java index e88047132de..740d51daf90 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/DateUtils.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/DateUtils.java @@ -20,6 +20,8 @@ package ca.uhn.fhir.util; import ca.uhn.fhir.i18n.Msg; +import com.google.common.base.Preconditions; +import jakarta.annotation.Nullable; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.ImmutablePair; import org.apache.commons.lang3.tuple.Pair; @@ -28,11 +30,21 @@ import java.lang.ref.SoftReference; import java.text.ParseException; import java.text.ParsePosition; import java.text.SimpleDateFormat; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoField; +import java.time.temporal.TemporalAccessor; +import java.time.temporal.TemporalAmount; +import java.time.temporal.TemporalField; import java.util.Calendar; import java.util.Date; import java.util.HashMap; import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.TimeZone; /** @@ -93,6 +105,66 @@ public final class DateUtils { */ private DateUtils() {} + // LUKETODO: javadoc + public static String formatWithZoneAndOptionalDelta( + LocalDateTime localDateTime, + ZoneId theZoneId, + DateTimeFormatter theDateTimeFormatter, + @Nullable TemporalAmount theDelta) { + final ZonedDateTime zonedDateTime = localDateTime.atZone(theZoneId); + + final ZonedDateTime zonedDateTimeToFormat = + Optional.ofNullable(theDelta).map(zonedDateTime::plus).orElse(zonedDateTime); + + return zonedDateTimeToFormat.format(theDateTimeFormatter); + } + + // LUKETODO: javadoc + public static Optional extractLocalDateTimeIfValid(TemporalAccessor theTemporalAccessor) { + if (theTemporalAccessor.isSupported(ChronoField.YEAR)) { + final int year = theTemporalAccessor.get(ChronoField.YEAR); + final int month = getTimeUnitIfSupported(theTemporalAccessor, ChronoField.MONTH_OF_YEAR, 1); + final int day = getTimeUnitIfSupported(theTemporalAccessor, ChronoField.DAY_OF_MONTH, 1); + final int hour = getTimeUnitIfSupported(theTemporalAccessor, ChronoField.HOUR_OF_DAY, 0); + final int minute = getTimeUnitIfSupported(theTemporalAccessor, ChronoField.MINUTE_OF_HOUR, 0); + final int seconds = getTimeUnitIfSupported(theTemporalAccessor, ChronoField.SECOND_OF_MINUTE, 0); + + return Optional.of(LocalDateTime.of(year, month, day, hour, minute, seconds)); + } + + return Optional.empty(); + } + + // LUKETODO: javadoc + public static Optional parseDateTimeStringIfValid( + String theDateTimeString, DateTimeFormatter theSupportedDateTimeFormatter) { + Preconditions.checkArgument(StringUtils.isNotBlank(theDateTimeString)); + + try { + return Optional.of(theSupportedDateTimeFormatter.parse(theDateTimeString)); + } catch (Exception exception) { + return Optional.empty(); + } + } + + // LUKETODO: javadoc + public static Optional getZoneOffsetIfSupported(TemporalAccessor theTemporalAccessor) { + if (theTemporalAccessor.isSupported(ChronoField.OFFSET_SECONDS)) { + return Optional.of(ZoneOffset.from(theTemporalAccessor)); + } + + return Optional.empty(); + } + + private static int getTimeUnitIfSupported( + TemporalAccessor theTemporalAccessor, TemporalField theTemporalField, int theDefaultValue) { + if (theTemporalAccessor.isSupported(theTemporalField)) { + return theTemporalAccessor.get(theTemporalField); + } + + return theDefaultValue; + } + /** * A factory for {@link SimpleDateFormat}s. The instances are stored in a * threadlocal way because SimpleDateFormat is not thread safe as noted in diff --git a/hapi-fhir-base/src/test/java/ca/uhn/fhir/util/DateUtilsTest.java b/hapi-fhir-base/src/test/java/ca/uhn/fhir/util/DateUtilsTest.java new file mode 100644 index 00000000000..899611a0e5a --- /dev/null +++ b/hapi-fhir-base/src/test/java/ca/uhn/fhir/util/DateUtilsTest.java @@ -0,0 +1,438 @@ +package ca.uhn.fhir.util; + +import jakarta.annotation.Nullable; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.Month; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAccessor; +import java.time.temporal.TemporalAmount; +import java.time.temporal.TemporalField; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DateUtilsTest { + + private static final Logger ourLog = LoggerFactory.getLogger(DateUtilsTest.class); + + private static final ZoneId TIMEZONE_NEWFOUNDLAND = ZoneId.of("America/St_Johns"); + + private static final DateTimeFormatter DATE_TIME_FORMATTER_YYYY_MM_DD = DateTimeFormatter.ISO_LOCAL_DATE; + private static final DateTimeFormatter DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + private static final Duration MINUS_ONE_SECOND = Duration.of(-1, ChronoUnit.SECONDS); + + + private static Stream extractLocalDateTimeIfValidParams() { + return Stream.of( + Arguments.of( + getTemporalAccessor(2024), + LocalDateTime.of(2024, Month.JANUARY, 1, 0, 0, 0) + ), + Arguments.of( + getTemporalAccessor(2023, 2), + LocalDateTime.of(2023, Month.FEBRUARY, 1, 0, 0, 0) + ), + Arguments.of( + getTemporalAccessor(2022, 9), + LocalDateTime.of(2022, Month.SEPTEMBER, 1, 0, 0, 0) + ), + Arguments.of( + getTemporalAccessor(2021, 3, 24), + LocalDateTime.of(2021, Month.MARCH, 24, 0, 0, 0) + ), + Arguments.of( + getTemporalAccessor(2024, 10, 23), + LocalDateTime.of(2024, Month.OCTOBER, 23, 0, 0, 0) + ), + Arguments.of( + getTemporalAccessor(2024, 8, 24, 12), + LocalDateTime.of(2024, Month.AUGUST, 24, 12, 0, 0) + ), + Arguments.of( + getTemporalAccessor(2024, 11, 24, 12, 35), + LocalDateTime.of(2024, Month.NOVEMBER, 24, 12, 35, 0) + ), + Arguments.of( + getTemporalAccessor(2024, 9, 24, 12, 35, 47), + LocalDateTime.of(2024, Month.SEPTEMBER, 24, 12, 35, 47) + ) + ); + } + + private static Stream formatWithZoneAndOptionalDeltaParams() { + return Stream.of( + Arguments.of( + LocalDateTime.of(2024, Month.JANUARY, 1, 0, 0, 0), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + null, + "2024-01-01T00:00:00Z" + ), + Arguments.of( + LocalDateTime.of(2024, Month.FEBRUARY, 1, 0, 0, 0), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + null, + "2024-02-01T00:00:00Z" + ), + Arguments.of( + LocalDateTime.of(2024, Month.AUGUST, 1, 0, 0, 0), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + null, + "2024-08-01T00:00:00Z" + ), + Arguments.of( + LocalDateTime.of(2024, Month.MARCH, 24, 12, 35, 47), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + null, + "2024-03-24T12:35:47Z" + ), + Arguments.of( + LocalDateTime.of(2024, Month.SEPTEMBER, 24, 12, 35, 47), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + null, + "2024-09-24T12:35:47Z" + ), + + Arguments.of( + LocalDateTime.of(2024, Month.JANUARY, 1, 0, 0, 0), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + MINUS_ONE_SECOND, + "2023-12-31T23:59:59Z" + ), + Arguments.of( + LocalDateTime.of(2024, Month.FEBRUARY, 1, 0, 0, 0), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + MINUS_ONE_SECOND, + "2024-01-31T23:59:59Z" + ), + Arguments.of( + LocalDateTime.of(2024, Month.AUGUST, 1, 0, 0, 0), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + MINUS_ONE_SECOND, + "2024-07-31T23:59:59Z" + ), + Arguments.of( + LocalDateTime.of(2024, Month.MARCH, 24, 12, 35, 47), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + MINUS_ONE_SECOND, + "2024-03-24T12:35:46Z" + ), + Arguments.of( + LocalDateTime.of(2024, Month.SEPTEMBER, 24, 12, 35, 47), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + MINUS_ONE_SECOND, + "2024-09-24T12:35:46Z" + ), + + Arguments.of( + LocalDateTime.of(2024, Month.JANUARY, 1, 0, 0, 0), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + null, + "2024-01-01T00:00:00-03:30" + ), + Arguments.of( + LocalDateTime.of(2024, Month.FEBRUARY, 1, 0, 0, 0), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + null, + "2024-02-01T00:00:00-03:30" + ), + Arguments.of( + LocalDateTime.of(2024, Month.AUGUST, 1, 0, 0, 0), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + null, + "2024-08-01T00:00:00-02:30" + ), + Arguments.of( + LocalDateTime.of(2024, Month.MARCH, 24, 12, 35, 47), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + null, + "2024-03-24T12:35:47-02:30" + ), + Arguments.of( + LocalDateTime.of(2024, Month.SEPTEMBER, 24, 12, 35, 47), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + null, + "2024-09-24T12:35:47-02:30" + ), + + Arguments.of( + LocalDateTime.of(2024, Month.JANUARY, 1, 0, 0, 0), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + MINUS_ONE_SECOND, + "2023-12-31T23:59:59-03:30" + ), + Arguments.of( + LocalDateTime.of(2024, Month.FEBRUARY, 1, 0, 0, 0), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + MINUS_ONE_SECOND, + "2024-01-31T23:59:59-03:30" + ), + Arguments.of( + LocalDateTime.of(2024, Month.AUGUST, 1, 0, 0, 0), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + MINUS_ONE_SECOND, + "2024-07-31T23:59:59-02:30" + ), + Arguments.of( + LocalDateTime.of(2024, Month.MARCH, 24, 12, 35, 47), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + MINUS_ONE_SECOND, + "2024-03-24T12:35:46-02:30" + ), + Arguments.of( + LocalDateTime.of(2024, Month.SEPTEMBER, 24, 12, 35, 47), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + MINUS_ONE_SECOND, + "2024-09-24T12:35:46-02:30" + ), + + Arguments.of( + LocalDateTime.of(2024, Month.JANUARY, 1, 0, 0, 0), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + null, + "2024-01-01T00:00:00Z" + ), + Arguments.of( + LocalDateTime.of(2024, Month.FEBRUARY, 1, 0, 0, 0), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + null, + "2024-02-01T00:00:00Z" + ), + Arguments.of( + LocalDateTime.of(2024, Month.AUGUST, 1, 0, 0, 0), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + null, + "2024-08-01T00:00:00Z" + ), + Arguments.of( + LocalDateTime.of(2024, Month.MARCH, 24, 12, 35, 47), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + null, + "2024-03-24T12:35:47Z" + ), + Arguments.of( + LocalDateTime.of(2024, Month.SEPTEMBER, 24, 12, 35, 47), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z, + null, + "2024-09-24T12:35:47Z" + ), + + Arguments.of( + LocalDateTime.of(2024, Month.JANUARY, 1, 0, 0, 0), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD, + MINUS_ONE_SECOND, + "2023-12-31" + ), + Arguments.of( + LocalDateTime.of(2024, Month.FEBRUARY, 1, 0, 0, 0), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD, + MINUS_ONE_SECOND, + "2024-01-31" + ), + Arguments.of( + LocalDateTime.of(2024, Month.AUGUST, 1, 0, 0, 0), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD, + MINUS_ONE_SECOND, + "2024-07-31" + ), + Arguments.of( + LocalDateTime.of(2024, Month.MARCH, 24, 12, 35, 47), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD, + MINUS_ONE_SECOND, + "2024-03-24" + ), + Arguments.of( + LocalDateTime.of(2024, Month.SEPTEMBER, 24, 12, 35, 47), + ZoneOffset.UTC, + DATE_TIME_FORMATTER_YYYY_MM_DD, + MINUS_ONE_SECOND, + "2024-09-24" + ), + + Arguments.of( + LocalDateTime.of(2024, Month.JANUARY, 1, 0, 0, 0), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD, + null, + "2024-01-01" + ), + Arguments.of( + LocalDateTime.of(2024, Month.FEBRUARY, 1, 0, 0, 0), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD, + null, + "2024-02-01" + ), + Arguments.of( + LocalDateTime.of(2024, Month.AUGUST, 1, 0, 0, 0), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD, + null, + "2024-08-01" + ), + Arguments.of( + LocalDateTime.of(2024, Month.MARCH, 24, 12, 35, 47), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD, + null, + "2024-03-24" + ), + Arguments.of( + LocalDateTime.of(2024, Month.SEPTEMBER, 24, 12, 35, 47), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD, + null, + "2024-09-24" + ), + + Arguments.of( + LocalDateTime.of(2024, Month.JANUARY, 1, 0, 0, 0), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD, + MINUS_ONE_SECOND, + "2023-12-31" + ), + Arguments.of( + LocalDateTime.of(2024, Month.FEBRUARY, 1, 0, 0, 0), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD, + MINUS_ONE_SECOND, + "2024-01-31" + ), + Arguments.of( + LocalDateTime.of(2024, Month.AUGUST, 1, 0, 0, 0), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD, + MINUS_ONE_SECOND, + "2024-07-31" + ), + Arguments.of( + LocalDateTime.of(2024, Month.MARCH, 24, 12, 35, 47), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD, + MINUS_ONE_SECOND, + "2024-03-24" + ), + Arguments.of( + LocalDateTime.of(2024, Month.SEPTEMBER, 24, 12, 35, 47), + TIMEZONE_NEWFOUNDLAND, + DATE_TIME_FORMATTER_YYYY_MM_DD, + MINUS_ONE_SECOND, + "2024-09-24" + ) + ); + } + + @ParameterizedTest + @MethodSource("extractLocalDateTimeIfValidParams") + void extractLocalDateTimeIfValid ( + TemporalAccessor theTemporalAccessor, + @Nullable LocalDateTime theExpectedResult) { + assertThat(DateUtils.extractLocalDateTimeIfValid(theTemporalAccessor)) + .isEqualTo(Optional.ofNullable(theExpectedResult)); + } + + @ParameterizedTest + @MethodSource("formatWithZoneAndOptionalDeltaParams") + void formatWithZoneAndOptionalDelta ( + LocalDateTime theLocalDateTime, + ZoneId theZoneId, + DateTimeFormatter theDateTimeFormatter, + @Nullable TemporalAmount theDelta, + String theExpectedResult) { + + assertThat(DateUtils.formatWithZoneAndOptionalDelta(theLocalDateTime, theZoneId, theDateTimeFormatter, theDelta)) + .isEqualTo(theExpectedResult); + } + + private static TemporalAccessor getTemporalAccessor(int theYear) { + return getTemporalAccessor(theYear, null, null, null, null, null); + } + + private static TemporalAccessor getTemporalAccessor(int theYear, int theMonth) { + return getTemporalAccessor(theYear, theMonth, null, null, null, null); + } + + private static TemporalAccessor getTemporalAccessor(int theYear, int theMonth, int theDay) { + return getTemporalAccessor(theYear, theMonth, theDay, null, null, null); + } + + private static TemporalAccessor getTemporalAccessor(int theYear, int theMonth, int theDay, int theHour) { + return getTemporalAccessor(theYear, theMonth, theDay, theHour, null, null); + } + + private static TemporalAccessor getTemporalAccessor(int theYear, int theMonth, int theDay, int theHour, int theMinute) { + return getTemporalAccessor(theYear, theMonth, theDay, theHour, theMinute, null); + } + + private static TemporalAccessor getTemporalAccessor( + int year, + @Nullable Integer month, + @Nullable Integer day, + @Nullable Integer hour, + @Nullable Integer minute, + @Nullable Integer second) { + final TemporalAccessor temporalAccessor = mock(TemporalAccessor.class); + + mockAccessor(temporalAccessor, ChronoField.YEAR, year); + mockAccessor(temporalAccessor, ChronoField.MONTH_OF_YEAR, month); + mockAccessor(temporalAccessor, ChronoField.DAY_OF_MONTH, day); + mockAccessor(temporalAccessor, ChronoField.HOUR_OF_DAY, hour); + mockAccessor(temporalAccessor, ChronoField.MINUTE_OF_HOUR, minute); + mockAccessor(temporalAccessor, ChronoField.SECOND_OF_MINUTE, second); + + return temporalAccessor; + } + + private static void mockAccessor(TemporalAccessor theTemporalAccessor, TemporalField theTemporalField, @Nullable Integer theValue) { + if (theValue != null) { + when(theTemporalAccessor.isSupported(theTemporalField)).thenReturn(true); + when(theTemporalAccessor.get(theTemporalField)).thenReturn(theValue); + } + } +} diff --git a/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/config/r4/CrR4Config.java b/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/config/r4/CrR4Config.java index 3cdc398e0c0..072e3a088d2 100644 --- a/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/config/r4/CrR4Config.java +++ b/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/config/r4/CrR4Config.java @@ -39,6 +39,7 @@ import ca.uhn.fhir.cr.r4.measure.CareGapsOperationProvider; import ca.uhn.fhir.cr.r4.measure.CollectDataOperationProvider; import ca.uhn.fhir.cr.r4.measure.DataRequirementsOperationProvider; import ca.uhn.fhir.cr.r4.measure.MeasureOperationsProvider; +import ca.uhn.fhir.cr.r4.measure.MeasureReportPeriodRequestProcessingService; import ca.uhn.fhir.cr.r4.measure.SubmitDataProvider; import ca.uhn.fhir.rest.server.RestfulServer; import org.opencds.cqf.fhir.cql.EvaluationSettings; @@ -57,6 +58,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import java.time.ZoneOffset; import java.util.Arrays; import java.util.Map; import java.util.concurrent.Executor; @@ -145,8 +147,11 @@ public class CrR4Config { } @Bean - MeasureOperationsProvider r4MeasureOperationsProvider() { - return new MeasureOperationsProvider(); + MeasureOperationsProvider r4MeasureOperationsProvider( + IMeasureServiceFactory theR4MeasureServiceFactory, + MeasureReportPeriodRequestProcessingService theMeasureReportPeriodRequestProcessingService) { + return new MeasureOperationsProvider( + theR4MeasureServiceFactory, theMeasureReportPeriodRequestProcessingService); } @Bean @@ -168,4 +173,9 @@ public class CrR4Config { return new ProviderLoader(theRestfulServer, theApplicationContext, selector); } + + @Bean + MeasureReportPeriodRequestProcessingService measureReportPeriodService() { + return new MeasureReportPeriodRequestProcessingService(ZoneOffset.UTC); + } } diff --git a/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/r4/measure/MeasureOperationsProvider.java b/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/r4/measure/MeasureOperationsProvider.java index c7cd5fbed34..27eaa8eb594 100644 --- a/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/r4/measure/MeasureOperationsProvider.java +++ b/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/r4/measure/MeasureOperationsProvider.java @@ -34,11 +34,17 @@ import org.hl7.fhir.r4.model.Measure; import org.hl7.fhir.r4.model.MeasureReport; import org.hl7.fhir.r4.model.Parameters; import org.opencds.cqf.fhir.utility.monad.Eithers; -import org.springframework.beans.factory.annotation.Autowired; public class MeasureOperationsProvider { - @Autowired - IMeasureServiceFactory myR4MeasureServiceFactory; + private final IMeasureServiceFactory myR4MeasureServiceFactory; + private final MeasureReportPeriodRequestProcessingService myMeasureReportPeriodRequestProcessingService; + + public MeasureOperationsProvider( + IMeasureServiceFactory theR4MeasureServiceFactory, + MeasureReportPeriodRequestProcessingService theMeasureReportPeriodRequestProcessingService) { + myR4MeasureServiceFactory = theR4MeasureServiceFactory; + myMeasureReportPeriodRequestProcessingService = theMeasureReportPeriodRequestProcessingService; + } /** * Implements the VALID_DATE_TIME_FORMATTERS_BY_FORMAT_LENGTH = Map.of( + 4, DATE_TIME_FORMATTER_YYYY_INPUT, + 7, DATE_TIME_FORMATTER_YYYY_MM_INPUT, + 10, DATE_TIME_FORMATTER_YYYY_MM_DD_INPUT, + 20, DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z_INPUT_OR_OUTPUT, + 25, DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z_INPUT_OR_OUTPUT); + + private final ZoneId myFallbackTimezone; + + public MeasureReportPeriodRequestProcessingService(ZoneId theFallbackTimezone) { + myFallbackTimezone = theFallbackTimezone; + } + + public MeasurePeriodForEvaluation validateAndProcessTimezone( + RequestDetails theRequestDetails, String thePeriodStart, String thePeriodEnd) { + final ZoneId clientTimezone = getClientTimezoneOrInvalidRequest(theRequestDetails); + + return validateInputDates(thePeriodStart, thePeriodEnd, clientTimezone); + } + + private MeasurePeriodForEvaluation validateInputDates( + String thePeriodStart, String thePeriodEnd, ZoneId theZoneId) { + if (Strings.isBlank(thePeriodStart) || Strings.isBlank(thePeriodEnd)) { + throw new InvalidRequestException( + String.format("Either start: [%s] or end: [%s] or both are blank", thePeriodStart, thePeriodEnd)); + } + + if (thePeriodStart.length() != thePeriodEnd.length()) { + throw new InvalidRequestException(String.format( + "Period start: %s and end: %s are not the same date/time formats", thePeriodStart, thePeriodEnd)); + } + + final DateTimeFormatter dateTimeFormatterStart = + VALID_DATE_TIME_FORMATTERS_BY_FORMAT_LENGTH.get(thePeriodStart.length()); + + if (dateTimeFormatterStart == null) { + throw new InvalidRequestException(String.format( + "Either start: %s or end: %s or both have an supported date/time format", + thePeriodStart, thePeriodEnd)); + } + + final Optional optTemporalAccessorStart = + DateUtils.parseDateTimeStringIfValid(thePeriodStart, dateTimeFormatterStart); + final Optional optTemporalAccessorEnd = + DateUtils.parseDateTimeStringIfValid(thePeriodEnd, dateTimeFormatterStart); + + if (optTemporalAccessorStart.isEmpty() || optTemporalAccessorEnd.isEmpty()) { + throw new InvalidRequestException("Either start or end period have an unsupported format"); + } + + final Optional optLocalDateTimeStart = + DateUtils.extractLocalDateTimeIfValid(optTemporalAccessorStart.get()); + final Optional optLocalDateTimeEnd = + DateUtils.extractLocalDateTimeIfValid(optTemporalAccessorEnd.get()); + + final Optional optZoneOffsetStart = + DateUtils.getZoneOffsetIfSupported(optTemporalAccessorStart.get()); + final Optional optZoneOffsetEnd = DateUtils.getZoneOffsetIfSupported(optTemporalAccessorEnd.get()); + + if (optLocalDateTimeStart.isEmpty() || optLocalDateTimeEnd.isEmpty()) { + throw new InvalidRequestException("Either start or end period have an unsupported format"); + } + + final LocalDateTime localDateTimeStart = optLocalDateTimeStart.get(); + final LocalDateTime localDateTimeEnd = optLocalDateTimeEnd.get(); + + if (localDateTimeStart.isEqual(localDateTimeEnd)) { + throw new InvalidRequestException( + String.format("Start date: %s is the same as end date: %s", thePeriodStart, thePeriodEnd)); + } + + if (localDateTimeStart.isAfter(localDateTimeEnd)) { + throw new InvalidRequestException( + String.format("Start date: %s is after end date: %s", thePeriodStart, thePeriodEnd)); + } + + if (!optZoneOffsetStart.equals(optZoneOffsetEnd)) { + throw new InvalidRequestException(String.format( + "Zone offsets do not match for period start: %s and end: %s", thePeriodStart, thePeriodEnd)); + } + + // Either the input Strings have no offset + + // Or the request timezone is different from the default + if (optZoneOffsetStart.isEmpty() || !myFallbackTimezone.getRules().equals(theZoneId.getRules())) { + + // Preserve backwards compatibility + if (optZoneOffsetStart.isPresent()) { + ourLog.warn( + "Start offset is not the same as the timezone header. Ignoring both start and stop offsets for start: {} amd: {}", + thePeriodStart, + thePeriodEnd); + } + + return new MeasurePeriodForEvaluation( + formatWithZonePeriodStart(localDateTimeStart, theZoneId), + formatWithZonePeriodEnd(localDateTimeEnd, theZoneId)); + } + + return new MeasurePeriodForEvaluation( + formatWithZonePeriodStart(localDateTimeStart, optZoneOffsetStart.get()), + formatWithZonePeriodEnd(localDateTimeEnd, optZoneOffsetEnd.get())); + } + + private String formatWithZonePeriodStart(LocalDateTime theLocalDateTime, ZoneId theZoneId) { + return formatWithZoneAndOptionalDelta(theLocalDateTime, theZoneId, null); + } + + private String formatWithZonePeriodEnd(LocalDateTime theLocalDateTime, ZoneId theZoneId) { + return formatWithZoneAndOptionalDelta(theLocalDateTime, theZoneId, MINUS_ONE_SECOND); + } + + private String formatWithZoneAndOptionalDelta( + LocalDateTime theLocalDateTime, ZoneId theZoneId, @Nullable TemporalAmount theDelta) { + return DateUtils.formatWithZoneAndOptionalDelta( + theLocalDateTime, theZoneId, DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z_INPUT_OR_OUTPUT, theDelta); + } + + private ZoneId getClientTimezoneOrInvalidRequest(RequestDetails theRequestDetails) { + final String clientTimezoneString = theRequestDetails.getHeader(Constants.HEADER_CLIENT_TIMEZONE); + + if (Strings.isNotBlank(clientTimezoneString)) { + try { + return ZoneId.of(clientTimezoneString); + } catch (Exception exception) { + throw new InvalidRequestException("Invalid value for Timezone header: " + clientTimezoneString); + } + } + + return myFallbackTimezone; + } +} diff --git a/hapi-fhir-storage-cr/src/test/java/ca/uhn/fhir/cr/r4/measure/MeasureReportPeriodRequestProcessingServiceTest.java b/hapi-fhir-storage-cr/src/test/java/ca/uhn/fhir/cr/r4/measure/MeasureReportPeriodRequestProcessingServiceTest.java new file mode 100644 index 00000000000..4bb3544cc7a --- /dev/null +++ b/hapi-fhir-storage-cr/src/test/java/ca/uhn/fhir/cr/r4/measure/MeasureReportPeriodRequestProcessingServiceTest.java @@ -0,0 +1,94 @@ +package ca.uhn.fhir.cr.r4.measure; + +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.api.server.SystemRequestDetails; +import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; +import jakarta.annotation.Nullable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MeasureReportPeriodRequestProcessingServiceTest { + + private final MeasureReportPeriodRequestProcessingService myTestSubject = new MeasureReportPeriodRequestProcessingService(ZoneOffset.UTC); + + @ParameterizedTest + @CsvSource( nullValues = {"null"}, + value={ + "null, 2020, 2021, 2020-01-01T00:00:00Z, 2020-12-31T23:59:59Z", + "UTC, 2020, 2021, 2020-01-01T00:00:00Z, 2020-12-31T23:59:59Z", + "America/St_Johns, 2020, 2021, 2020-01-01T00:00:00-03:30, 2020-12-31T23:59:59-03:30", + "America/Toronto, 2020, 2021, 2020-01-01T00:00:00-05:00, 2020-12-31T23:59:59-05:00", + "America/Denver, 2020, 2021, 2020-01-01T00:00:00-07:00, 2020-12-31T23:59:59-07:00", + + "null, 2022-02, 2022-08, 2022-02-01T00:00:00Z, 2022-07-31T23:59:59Z", + "UTC, 2022-02, 2022-08, 2022-02-01T00:00:00Z, 2022-07-31T23:59:59Z", + "America/St_Johns, 2022-02, 2022-08, 2022-02-01T00:00:00-03:30, 2022-07-31T23:59:59-02:30", + "America/Toronto, 2022-02, 2022-08, 2022-02-01T00:00:00-05:00, 2022-07-31T23:59:59-04:00", + "America/Denver, 2022-02, 2022-08, 2022-02-01T00:00:00-07:00, 2022-07-31T23:59:59-06:00", + + "null, 2024-02-25, 2024-02-26, 2024-02-25T00:00:00Z, 2024-02-25T23:59:59Z", + "UTC, 2024-02-25, 2024-02-26, 2024-02-25T00:00:00Z, 2024-02-25T23:59:59Z", + "America/St_Johns, 2024-02-25, 2024-02-26, 2024-02-25T00:00:00-03:30, 2024-02-25T23:59:59-03:30", + "America/Toronto, 2024-02-25, 2024-02-26, 2024-02-25T00:00:00-05:00, 2024-02-25T23:59:59-05:00", + "America/Denver, 2024-02-25, 2024-02-26, 2024-02-25T00:00:00-07:00, 2024-02-25T23:59:59-07:00", + + "null, 2024-09-25, 2024-09-26, 2024-09-25T00:00:00Z, 2024-09-25T23:59:59Z", + "UTC, 2024-09-25, 2024-09-26, 2024-09-25T00:00:00Z, 2024-09-25T23:59:59Z", + "America/St_Johns, 2024-09-25, 2024-09-26, 2024-09-25T00:00:00-02:30, 2024-09-25T23:59:59-02:30", + "America/Toronto, 2024-09-25, 2024-09-26, 2024-09-25T00:00:00-04:00, 2024-09-25T23:59:59-04:00", + "America/Denver, 2024-09-25, 2024-09-26, 2024-09-25T00:00:00-06:00, 2024-09-25T23:59:59-06:00", + + "null, 2024-09-25T12:00:00-06:00, 2024-09-26T12:00:00-06:00, 2024-09-25T12:00:00-06:00, 2024-09-26T11:59:59-06:00", + "UTC, 2024-09-25T12:00:00-06:00, 2024-09-26T12:00:00-06:00, 2024-09-25T12:00:00-06:00, 2024-09-26T11:59:59-06:00", + "America/St_Johns, 2024-09-25T12:00:00-06:00, 2024-09-26T12:00:00-06:00, 2024-09-25T12:00:00-02:30, 2024-09-26T11:59:59-02:30", + "America/Toronto, 2024-09-25T12:00:00-06:00, 2024-09-26T12:00:00-06:00, 2024-09-25T12:00:00-04:00, 2024-09-26T11:59:59-04:00", + "America/Denver, 2024-09-25T12:00:00-06:00, 2024-09-26T12:00:00-06:00, 2024-09-25T12:00:00-06:00, 2024-09-26T11:59:59-06:00", + } + ) + void validateAndProcessTimezone_happyPath(@Nullable String theTimezone, String theInputPeriodStart, String theInputPeriodEnd, String theOutputPeriodStart, String theOutputPeriodEnd) { + + final MeasurePeriodForEvaluation actualResult = + myTestSubject.validateAndProcessTimezone(getRequestDetails(theTimezone), theInputPeriodStart, theInputPeriodEnd); + + final MeasurePeriodForEvaluation expectedResult = new MeasurePeriodForEvaluation(theOutputPeriodStart, theOutputPeriodEnd); + assertThat(actualResult).isEqualTo(expectedResult); + } + + public static Stream errorParams() { + return Stream.of( + Arguments.of(null, null, null, new InvalidRequestException("Either start: [null] or end: [null] or both are blank")), + Arguments.of(null, "", "", new InvalidRequestException("Either start: [] or end: [] or both are blank")), + Arguments.of(null, "2024", "2024-01", new InvalidRequestException("Period start: 2024 and end: 2024-01 are not the same date/time formats")), + Arguments.of(null, "2024-01-01T12", "2024-01-01T12", new InvalidRequestException("Either start: 2024-01-01T12 or end: 2024-01-01T12 or both have an supported date/time format")), + Arguments.of(null, "2024-01-01T12:00:00-02:30", "2024-01-02T12:00:00-04:00", new InvalidRequestException("Zone offsets do not match for period start: 2024-01-01T12:00:00-02:30 and end: 2024-01-02T12:00:00-04:00")), + Arguments.of(null, "2024-01-01", "2024-01-01", new InvalidRequestException("Start date: 2024-01-01 is the same as end date: 2024-01-01")), + Arguments.of(null, "2024-01-02", "2024-01-01", new InvalidRequestException("Start date: 2024-01-02 is after end date: 2024-01-01")), + Arguments.of("Middle-Earth/Combe", "2024-01-02", "2024-01-03", new InvalidRequestException("Invalid value for Timezone header: Middle-Earth/Combe")) + ); + } + + @ParameterizedTest + @MethodSource("errorParams") + void validateAndProcessTimezone_errorPaths(@Nullable String theTimezone, @Nullable String theInputPeriodStart, @Nullable String theInputPeriodEnd, InvalidRequestException theExpectedResult) { + assertThatThrownBy(() -> myTestSubject.validateAndProcessTimezone(getRequestDetails(theTimezone), theInputPeriodStart, theInputPeriodEnd)) + .hasMessage(theExpectedResult.getMessage()) + .isInstanceOf(theExpectedResult.getClass()); + } + + private static RequestDetails getRequestDetails(@Nullable String theTimezone) { + final SystemRequestDetails systemRequestDetails = new SystemRequestDetails(); + Optional.ofNullable(theTimezone) + .ifPresent(nonNullTimezone -> systemRequestDetails .addHeader(Constants.HEADER_CLIENT_TIMEZONE, nonNullTimezone)); + return systemRequestDetails; + } +}