Add request timezone, validation, and period start and end manipulation logic to $evaluate-measure.

This commit is contained in:
Luke deGruchy 2024-09-25 17:09:31 -04:00
parent 1aef189069
commit 7fbdf30b72
7 changed files with 922 additions and 7 deletions

View File

@ -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<LocalDateTime> 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<TemporalAccessor> 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<ZoneOffset> 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

View File

@ -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<Arguments> 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<Arguments> 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);
}
}
}

View File

@ -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);
}
}

View File

@ -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 <a href=
@ -78,12 +84,17 @@ public class MeasureOperationsProvider {
@OperationParam(name = "parameters") Parameters theParameters,
RequestDetails theRequestDetails)
throws InternalErrorException, FHIRException {
final MeasurePeriodForEvaluation measurePeriodForEvaluation =
myMeasureReportPeriodRequestProcessingService.validateAndProcessTimezone(
theRequestDetails, thePeriodStart, thePeriodEnd);
return myR4MeasureServiceFactory
.create(theRequestDetails)
.evaluate(
Eithers.forMiddle3(theId),
thePeriodStart,
thePeriodEnd,
measurePeriodForEvaluation.getPeriodStart(),
measurePeriodForEvaluation.getPeriodEnd(),
theReportType,
theSubject,
theLastReceivedOn,

View File

@ -0,0 +1,68 @@
/*-
* #%L
* HAPI FHIR - Clinical Reasoning
* %%
* Copyright (C) 2014 - 2024 Smile CDR, Inc.
* %%
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* #L%
*/
package ca.uhn.fhir.cr.r4.measure;
import java.util.Objects;
import java.util.StringJoiner;
// LUKETODO: javadoc
// TODO: LD: make this a record when hapi-fhir supports JDK 17
public class MeasurePeriodForEvaluation {
private final String myPeriodStart;
private final String myPeriodEnd;
public MeasurePeriodForEvaluation(String thePeriodStart, String thePeriodEnd) {
myPeriodStart = thePeriodStart;
myPeriodEnd = thePeriodEnd;
}
public String getPeriodStart() {
return myPeriodStart;
}
public String getPeriodEnd() {
return myPeriodEnd;
}
@Override
public boolean equals(Object theO) {
if (this == theO) {
return true;
}
if (theO == null || getClass() != theO.getClass()) {
return false;
}
MeasurePeriodForEvaluation that = (MeasurePeriodForEvaluation) theO;
return Objects.equals(myPeriodStart, that.myPeriodStart) && Objects.equals(myPeriodEnd, that.myPeriodEnd);
}
@Override
public int hashCode() {
return Objects.hash(myPeriodStart, myPeriodEnd);
}
@Override
public String toString() {
return new StringJoiner(", ", MeasurePeriodForEvaluation.class.getSimpleName() + "[", "]")
.add("myPeriodStart='" + myPeriodStart + "'")
.add("myPeriodEnd='" + myPeriodEnd + "'")
.toString();
}
}

View File

@ -0,0 +1,222 @@
/*-
* #%L
* HAPI FHIR - Clinical Reasoning
* %%
* Copyright (C) 2014 - 2024 Smile CDR, Inc.
* %%
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* #L%
*/
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.server.exceptions.InvalidRequestException;
import ca.uhn.fhir.util.DateUtils;
import jakarta.annotation.Nullable;
import org.apache.logging.log4j.util.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAccessor;
import java.time.temporal.TemporalAmount;
import java.util.Map;
import java.util.Optional;
// LUKETODO: copyright header
// LUKETODO: javadoc
// LUKETODO: unit test
/*
according to the docs: periodStart and periodEnd support Dates (YYYY, YYYY-MM, or YYYY-MM-DD) and DateTimes (YYYY-MM-DDThh:mm:ss+zz:zz)
if the user passes in:
Header: America/Toronto
periodStart 2024-09-25T12:00:00-06:00
which timezone do we respect, or do we convert the periodStart to EDT?
also, what happens if they pass in start and end in different formats?
periodStart 2024-09-25T12:00:00-06:00"
periodEnd: 2024-10-01
also, if we dont already, Ill return a 400 if they pass a start thats after an end
*/
/*
| request input | request timezone | delta | converted input to CR |
| --------------| ---------------- | ------ | ------------------------- |
| 2024-09-24 | null | null | 2024-09-24T00:00:00Z |
| 2024-09-24 | UTC | null | 2024-09-24T00:00:00Z |
| 2024-09-24 | Newfoundland | null | 2024-09-24T00:00:00-02:30 |
| 2024-09-24 | Eastern | null | 2024-09-24T00:00:00-04:00 |
| 2024-09-24 | Mountain | null | 2024-09-24T00:00:00-06:00 |
| 2024-02-24 | null | null | 2024-02-24T00:00:00Z |
| 2024-02-24 | UTC | null | 2024-02-24T00:00:00Z |
| 2024-02-24 | Newfoundland | null | 2024-02-24T00:00:00-03:30 |
| 2024-02-24 | Eastern | null | 2024-02-24T00:00:00-05:00 |
| 2024-02-24 | Mountain | null | 2024-02-24T00:00:00-07:00 |
| 2024-09-24 | null | -1 sec | 2024-09-24T23:00:59Z |
| 2024-09-24 | UTC | -1 sec | 2024-09-24T23:59:59Z |
| 2024-09-24 | Newfoundland | -1 sec | 2024-09-24T23:59:59-02:30 |
| 2024-09-24 | Eastern | -1 sec | 2024-09-24T23:59:59-04:00 |
| 2024-09-24 | Mountain | -1 sec | 2024-09-24T23:59:59-06:00 |
| 2024-02-24 | null | -1 sec | 2024-02-24T23:59:59Z |
| 2024-02-24 | UTC | -1 sec | 2024-02-24T23:59:59Z |
| 2024-02-24 | Newfoundland | -1 sec | 2024-02-24T23:59:59-03:30 |
| 2024-02-24 | Eastern | -1 sec | 2024-02-24T23:59:59-05:00 |
| 2024-02-24 | Mountain | -1 sec | 2024-02-24T23:59:59-07:00 |
*/
public class MeasureReportPeriodRequestProcessingService {
private static final Logger ourLog = LoggerFactory.getLogger(MeasureReportPeriodRequestProcessingService.class);
private static final DateTimeFormatter DATE_TIME_FORMATTER_YYYY_INPUT = DateTimeFormatter.ofPattern("yyyy");
private static final DateTimeFormatter DATE_TIME_FORMATTER_YYYY_MM_INPUT = DateTimeFormatter.ofPattern("yyyy-MM");
private static final DateTimeFormatter DATE_TIME_FORMATTER_YYYY_MM_DD_INPUT = DateTimeFormatter.ISO_DATE;
private static final DateTimeFormatter DATE_TIME_FORMATTER_YYYY_MM_DD_HH_MM_SS_Z_INPUT_OR_OUTPUT =
DateTimeFormatter.ISO_OFFSET_DATE_TIME;
private static final Duration MINUS_ONE_SECOND = Duration.of(-1, ChronoUnit.SECONDS);
private static final Map<Integer, DateTimeFormatter> 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<TemporalAccessor> optTemporalAccessorStart =
DateUtils.parseDateTimeStringIfValid(thePeriodStart, dateTimeFormatterStart);
final Optional<TemporalAccessor> optTemporalAccessorEnd =
DateUtils.parseDateTimeStringIfValid(thePeriodEnd, dateTimeFormatterStart);
if (optTemporalAccessorStart.isEmpty() || optTemporalAccessorEnd.isEmpty()) {
throw new InvalidRequestException("Either start or end period have an unsupported format");
}
final Optional<LocalDateTime> optLocalDateTimeStart =
DateUtils.extractLocalDateTimeIfValid(optTemporalAccessorStart.get());
final Optional<LocalDateTime> optLocalDateTimeEnd =
DateUtils.extractLocalDateTimeIfValid(optTemporalAccessorEnd.get());
final Optional<ZoneOffset> optZoneOffsetStart =
DateUtils.getZoneOffsetIfSupported(optTemporalAccessorStart.get());
final Optional<ZoneOffset> 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;
}
}

View File

@ -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<Arguments> 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;
}
}