diff --git a/src/main/java/org/karnak/backend/model/profiles/ActionDates.java b/src/main/java/org/karnak/backend/model/profiles/ActionDates.java index 81c5a4f2..6b473c7e 100644 --- a/src/main/java/org/karnak/backend/model/profiles/ActionDates.java +++ b/src/main/java/org/karnak/backend/model/profiles/ActionDates.java @@ -22,6 +22,7 @@ import org.karnak.backend.model.profilepipe.HMAC; import org.karnak.backend.model.profilepipe.TagActionMap; import org.karnak.backend.util.DateFormat; +import org.karnak.backend.util.ShiftApiDate; import org.karnak.backend.util.ShiftByTagDate; import org.karnak.backend.util.ShiftDate; import org.karnak.backend.util.ShiftRangeDate; @@ -48,15 +49,16 @@ public ActionDates(ProfileElementEntity profileElementEntity) throws ProfileExce public void profileValidation() throws ProfileException { if (option == null) { throw new ProfileException("Cannot build the profile " + codeName - + " : An option must be given. Option available: [shift, shift_range, shift_by_tag, date_format]"); + + " : An option must be given. Option available: [shift, shift_range, shift_by_tag, shift_from_api, date_format]"); } switch (option) { case "shift" -> ShiftDate.verifyShiftArguments(argumentEntities); case "shift_range" -> ShiftRangeDate.verifyShiftArguments(argumentEntities); case "shift_by_tag" -> ShiftByTagDate.verifyShiftArguments(argumentEntities); + case "shift_from_api" -> ShiftApiDate.verifyShiftArguments(argumentEntities); case "date_format" -> DateFormat.verifyPatternArguments(argumentEntities); default -> throw new ProfileException("Cannot build the profile " + codeName + " with the option given " - + option + " : Option available (shift, shift_range, shift_by_tag, date_format)"); + + option + " : Option available (shift, shift_range, shift_by_tag, shift_from_api, date_format)"); } validateCondition(); @@ -96,6 +98,7 @@ public void profileValidation() throws ProfileException { case "shift" -> ShiftDate.shift(dcmCopy, tag, argumentEntities); case "shift_range" -> ShiftRangeDate.shift(dcmCopy, tag, argumentEntities, hmac); case "shift_by_tag" -> ShiftByTagDate.shift(dcmCopy, tag, argumentEntities, hmac); + case "shift_from_api" -> ShiftApiDate.shift(dcmCopy, tag, argumentEntities, hmac); case "date_format" -> DateFormat.format(dcmCopy, tag, argumentEntities); default -> null; }; diff --git a/src/main/java/org/karnak/backend/util/ShiftApiDate.java b/src/main/java/org/karnak/backend/util/ShiftApiDate.java new file mode 100644 index 00000000..747a9ed1 --- /dev/null +++ b/src/main/java/org/karnak/backend/util/ShiftApiDate.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2021-2026 Karnak Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.karnak.backend.util; + +import static org.karnak.backend.service.EndpointService.evaluateStringWithExpression; +import static org.karnak.backend.service.EndpointService.validateStringWithExpression; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.DateTimeException; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.dcm4che3.data.Attributes; +import org.karnak.backend.data.entity.ArgumentEntity; +import org.karnak.backend.exception.AbortException; +import org.karnak.backend.exception.EndpointException; +import org.karnak.backend.model.profilepipe.HMAC; +import org.karnak.backend.service.ApplicationContextProvider; +import org.karnak.backend.service.EndpointService; +import org.springframework.web.client.HttpClientErrorException; +import org.weasis.dicom.param.AttributeEditorContext; + +@Slf4j +public class ShiftApiDate { + + private static EndpointService endpointService; + + private ShiftApiDate() { + } + + public static void verifyShiftArguments(List argumentEntities) throws IllegalArgumentException { + if (argumentEntities == null || argumentEntities.isEmpty()) { + throw new IllegalArgumentException( + "Cannot build the option ShiftApiDate: Missing argument, url and days_path are required"); + } + + boolean urlProvided = false; + boolean daysPathProvided = false; + boolean isPost = false; + boolean bodyProvided = false; + + for (ArgumentEntity ae : argumentEntities) { + final String key = ae.getArgumentKey(); + if ("url".equals(key)) { + urlProvided = true; + String error = validateStringWithExpression(ae.getArgumentValue()); + if (error != null) { + throw new IllegalArgumentException(String.format("Expression is not valid: \n\r%s", error)); + } + } + else if ("days_path".equals(key)) { + daysPathProvided = true; + } + else if ("method".equals(key)) { + if (!ae.getArgumentValue().equalsIgnoreCase("post") && !ae.getArgumentValue().equalsIgnoreCase("get")) { + throw new IllegalArgumentException( + "Cannot build the option ShiftApiDate: method must be get or post"); + } + if (ae.getArgumentValue().equalsIgnoreCase("post")) { + isPost = true; + } + } + else if ("body".equals(key)) { + bodyProvided = true; + String error = validateStringWithExpression(ae.getArgumentValue()); + if (error != null) { + throw new IllegalArgumentException(String.format("Expression is not valid: \n\r%s", error)); + } + } + } + + if (!urlProvided) { + throw new IllegalArgumentException("Cannot build the option ShiftApiDate: url argument is mandatory"); + } + if (!daysPathProvided) { + throw new IllegalArgumentException("Cannot build the option ShiftApiDate: days_path argument is mandatory"); + } + if (isPost && !bodyProvided) { + throw new IllegalArgumentException( + "Cannot build the option ShiftApiDate: body argument is mandatory for a POST request"); + } + } + + public static String shift(Attributes dcmCopy, int tag, List argumentEntities, HMAC hmac) + throws DateTimeException { + verifyShiftArguments(argumentEntities); + + String url = null; + String daysPath = null; + String secondsPath = null; + String method = "get"; + String body = null; + String authConfig = null; + + for (ArgumentEntity ae : argumentEntities) { + switch (ae.getArgumentKey()) { + case "url" -> url = ae.getArgumentValue(); + case "days_path" -> daysPath = normalizeJsonPath(ae.getArgumentValue()); + case "seconds_path" -> secondsPath = normalizeJsonPath(ae.getArgumentValue()); + case "method" -> method = ae.getArgumentValue(); + case "body" -> body = ae.getArgumentValue(); + case "authConfig" -> authConfig = ae.getArgumentValue(); + default -> { + } + } + } + + url = evaluateStringWithExpression(url, dcmCopy); + if (body != null) { + body = evaluateStringWithExpression(body, dcmCopy); + } + + String response = fetchResponse(authConfig, url, method, body); + int shiftDays = parseShiftValue(response, daysPath, "days_path"); + int shiftSeconds = 0; + if (secondsPath != null) { + shiftSeconds = parseShiftValue(response, secondsPath, "seconds_path"); + } + + String dcmElValue = dcmCopy.getString(tag); + return ShiftDate.shiftValue(dcmCopy, tag, dcmElValue, shiftDays, shiftSeconds); + } + + private static String normalizeJsonPath(String path) { + if (path != null && !path.startsWith("/")) { + return "/" + path; + } + return path; + } + + private static String fetchResponse(String authConfig, String url, String method, String body) { + if (endpointService == null) { + endpointService = ApplicationContextProvider.bean(EndpointService.class); + } + + try { + if (method.equalsIgnoreCase("post")) { + return endpointService.post(authConfig, url, body); + } + if (method.equalsIgnoreCase("get")) { + return endpointService.get(authConfig, url); + } + throw new EndpointException("Unsupported HTTP Method : " + method); + } + catch (IllegalArgumentException e) { + throw new EndpointException(e.getMessage()); + } + catch (HttpClientErrorException e) { + throw new EndpointException("HTTP Client Error : " + e.getStatusText() + " - " + url); + } + } + + private static int parseShiftValue(String response, String jsonPath, String argumentName) { + try { + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode node = objectMapper.readTree(response).at(jsonPath); + if (node == null || node.isMissingNode() || node.isNull()) { + throw new AbortException(AttributeEditorContext.Abort.CONNECTION_EXCEPTION, + "Transfer aborted, shift value not found in response - " + argumentName + " (" + jsonPath + + ")"); + } + String textValue = node.isNumber() ? String.valueOf(node.intValue()) : node.asText(); + if (textValue == null || textValue.isEmpty()) { + throw new AbortException(AttributeEditorContext.Abort.CONNECTION_EXCEPTION, + "Transfer aborted, shift value not found in response - " + argumentName + " (" + jsonPath + + ")"); + } + return Integer.parseInt(textValue); + } + catch (JsonProcessingException e) { + throw new EndpointException("An error occurred while parsing the JSON response ", e); + } + catch (NumberFormatException e) { + throw new AbortException(AttributeEditorContext.Abort.CONNECTION_EXCEPTION, + "Transfer aborted, shift value is not a valid integer - " + argumentName + " (" + jsonPath + ")"); + } + } + +} diff --git a/src/test/java/org/karnak/profilepipe/option/datemanager/ShiftApiDateTest.java b/src/test/java/org/karnak/profilepipe/option/datemanager/ShiftApiDateTest.java new file mode 100644 index 00000000..b5ee17b5 --- /dev/null +++ b/src/test/java/org/karnak/profilepipe/option/datemanager/ShiftApiDateTest.java @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2025-2026 Karnak Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.karnak.profilepipe.option.datemanager; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.Tag; +import org.dcm4che3.data.VR; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.karnak.backend.data.entity.ArgumentEntity; +import org.karnak.backend.data.entity.IncludedTagEntity; +import org.karnak.backend.data.entity.ProfileElementEntity; +import org.karnak.backend.data.entity.ProfileEntity; +import org.karnak.backend.exception.AbortException; +import org.karnak.backend.exception.EndpointException; +import org.karnak.backend.model.profilepipe.HMAC; +import org.karnak.backend.service.ApplicationContextProvider; +import org.karnak.backend.service.EndpointService; +import org.karnak.backend.service.profilepipe.Profile; +import org.karnak.backend.util.ShiftApiDate; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.TestPropertySource; +import org.springframework.web.client.HttpClientErrorException; + +@SpringBootTest +@TestPropertySource(properties = { "spring.cache.type=none" // Disable caching for tests +}) +class ShiftApiDateTest { + + private static EndpointService endpointService; + + private static MockedStatic acp; + + private static final String AUTH_CONFIG = "endpoint"; + + private static final String TEST_URL = "http://sample.url.com/patient/97035674/day-shift"; + + private static final String TEST_URL_TEMPLATE = "http://sample.url.com/patient/{{getString(#Tag.PatientID)}}/day-shift"; + + private static final String TEST_UNKNOWN_URL = "http://sample.unknown.com/day-shift"; + + private static final HMAC hmac = new HMAC(HMAC.generateRandomKey()); + + private static final Attributes dataset = new Attributes(); + + private static final List argumentEntities = new ArrayList<>(); + + @BeforeAll + static void setUpStatic() { + endpointService = Mockito.mock(EndpointService.class); + acp = Mockito.mockStatic(ApplicationContextProvider.class); + } + + @BeforeEach + void setUp() { + reset(endpointService); + argumentEntities.clear(); + + dataset.clear(); + dataset.setString(Tag.PatientID, VR.LO, "97035674"); + dataset.setString(Tag.StudyDate, VR.DA, "20180209"); + dataset.setString(Tag.StudyTime, VR.TM, "120843"); + dataset.setString(Tag.PatientAge, VR.AS, "043Y"); + dataset.setString(Tag.AcquisitionDateTime, VR.DT, "20180209120854.354"); + dataset.setString(Tag.SeriesDate, VR.DA, "20180209"); + + when(endpointService.get(AUTH_CONFIG, TEST_URL)).thenReturn("{\"value\": 10}"); + when(endpointService.get(null, TEST_URL)).thenReturn("{\"value\": \"10\"}"); + when(endpointService.get(AUTH_CONFIG, TEST_URL + "/seconds")).thenReturn("{\"value\": 10, \"seconds\": 500}"); + when(endpointService.get(AUTH_CONFIG, TEST_UNKNOWN_URL)) + .thenThrow(new HttpClientErrorException(HttpStatus.NOT_FOUND)); + when(endpointService.get(AUTH_CONFIG, TEST_URL + "/missing")).thenReturn("{\"id\": 411}"); + when(endpointService.get(AUTH_CONFIG, TEST_URL + "/invalid")).thenReturn("{\"value\": \"not-a-number\"}"); + + acp.when(() -> ApplicationContextProvider.bean(EndpointService.class)).thenReturn(endpointService); + } + + private void addDefaultArguments() { + ArgumentEntity url = new ArgumentEntity(); + url.setArgumentKey("url"); + url.setArgumentValue(TEST_URL); + ArgumentEntity daysPath = new ArgumentEntity(); + daysPath.setArgumentKey("days_path"); + daysPath.setArgumentValue("/value"); + ArgumentEntity authConfig = new ArgumentEntity(); + authConfig.setArgumentKey("authConfig"); + authConfig.setArgumentValue(AUTH_CONFIG); + argumentEntities.add(url); + argumentEntities.add(daysPath); + argumentEntities.add(authConfig); + } + + @Test + void shiftStudyDateByDaysFromApi() { + addDefaultArguments(); + assertEquals("20180130", ShiftApiDate.shift(dataset, Tag.StudyDate, argumentEntities, hmac)); + } + + @Test + void shiftStudyTimeWithSecondsFromApi() { + addDefaultArguments(); + ArgumentEntity secondsPath = new ArgumentEntity(); + secondsPath.setArgumentKey("seconds_path"); + secondsPath.setArgumentValue("/seconds"); + argumentEntities.get(0).setArgumentValue(TEST_URL + "/seconds"); + argumentEntities.add(secondsPath); + + assertEquals("120023.000000", ShiftApiDate.shift(dataset, Tag.StudyTime, argumentEntities, hmac)); + } + + @Test + void shiftPatientAgeFromApi() { + addDefaultArguments(); + assertEquals("043Y", ShiftApiDate.shift(dataset, Tag.PatientAge, argumentEntities, hmac)); + } + + @Test + void shiftAcquisitionDateTimeFromApi() { + addDefaultArguments(); + assertEquals("20180130120854.354000", + ShiftApiDate.shift(dataset, Tag.AcquisitionDateTime, argumentEntities, hmac)); + } + + @Test + void urlTemplateResolvesPatientId() { + ArgumentEntity url = new ArgumentEntity(); + url.setArgumentKey("url"); + url.setArgumentValue(TEST_URL_TEMPLATE); + ArgumentEntity daysPath = new ArgumentEntity(); + daysPath.setArgumentKey("days_path"); + daysPath.setArgumentValue("value"); + ArgumentEntity authConfig = new ArgumentEntity(); + authConfig.setArgumentKey("authConfig"); + authConfig.setArgumentValue(AUTH_CONFIG); + argumentEntities.add(url); + argumentEntities.add(daysPath); + argumentEntities.add(authConfig); + + assertEquals("20180130", ShiftApiDate.shift(dataset, Tag.StudyDate, argumentEntities, hmac)); + } + + @Test + void httpErrorShouldAbort() { + addDefaultArguments(); + argumentEntities.get(0).setArgumentValue(TEST_UNKNOWN_URL); + assertThrows(EndpointException.class, () -> ShiftApiDate.shift(dataset, Tag.StudyDate, argumentEntities, hmac)); + } + + @Test + void missingDaysPathInResponseShouldAbort() { + addDefaultArguments(); + argumentEntities.get(0).setArgumentValue(TEST_URL + "/missing"); + assertThrows(AbortException.class, () -> ShiftApiDate.shift(dataset, Tag.StudyDate, argumentEntities, hmac)); + } + + @Test + void nonNumericShiftValueShouldAbort() { + addDefaultArguments(); + argumentEntities.get(0).setArgumentValue(TEST_URL + "/invalid"); + assertThrows(AbortException.class, () -> ShiftApiDate.shift(dataset, Tag.StudyDate, argumentEntities, hmac)); + } + + @Test + void verifyShiftArgumentsMissingUrl() { + ArgumentEntity daysPath = new ArgumentEntity(); + daysPath.setArgumentKey("days_path"); + daysPath.setArgumentValue("/value"); + assertThrows(IllegalArgumentException.class, () -> ShiftApiDate.verifyShiftArguments(List.of(daysPath))); + } + + @Test + void verifyShiftArgumentsMissingDaysPath() { + ArgumentEntity url = new ArgumentEntity(); + url.setArgumentKey("url"); + url.setArgumentValue(TEST_URL); + assertThrows(IllegalArgumentException.class, () -> ShiftApiDate.verifyShiftArguments(List.of(url))); + } + + @Test + void profileApplyActionShiftsMultipleDateTagsFromSingleApiResponse() { + final Attributes dcm = new Attributes(); + dcm.setString(Tag.PatientID, VR.LO, "97035674"); + dcm.setString(Tag.StudyDate, VR.DA, "20180209"); + dcm.setString(Tag.SeriesDate, VR.DA, "20180209"); + + ProfileEntity profileEntity = new ProfileEntity("TEST", "0.9.1", "0.9.1", "DPA"); + ProfileElementEntity profileElementEntity = new ProfileElementEntity("Shift dates from API", "action.on.dates", + null, null, "shift_from_api", 0, profileEntity); + profileElementEntity.addArgument(new ArgumentEntity("url", TEST_URL_TEMPLATE, profileElementEntity)); + profileElementEntity.addArgument(new ArgumentEntity("days_path", "/value", profileElementEntity)); + profileElementEntity.addArgument(new ArgumentEntity("authConfig", AUTH_CONFIG, profileElementEntity)); + profileElementEntity.addIncludedTag(new IncludedTagEntity("(0008,0020)", profileElementEntity)); + profileElementEntity.addIncludedTag(new IncludedTagEntity("(0008,0021)", profileElementEntity)); + profileElementEntity.setProfileEntity(profileEntity); + + profileEntity.addProfilePipe(profileElementEntity); + Profile profile = new Profile(profileEntity); + profile.applyAction(dcm, dcm, hmac, null, null, null); + + assertEquals("20180130", dcm.getString(Tag.StudyDate)); + assertEquals("20180130", dcm.getString(Tag.SeriesDate)); + } + + @org.junit.jupiter.api.AfterAll + static void tearDownStatic() { + if (acp != null) { + acp.close(); + } + } + +}