From d44b612e25f547d4052fadea8d55efa5c39aad7b Mon Sep 17 00:00:00 2001 From: Simon Schweizer Date: Sun, 26 Jul 2026 08:23:42 +0200 Subject: [PATCH] add support for the FHIR PATCH operation (FHIRPath Patch) Implements the FHIR PATCH interaction using the FHIRPath Patch format (Parameters resource), both as a direct REST interaction (PATCH [type]/[id] and conditional PATCH [type]?[criteria]) and inside transaction and batch bundles. - new FhirPathPatchService applies add/insert/delete/replace/move on the HAPI R4 runtime model without an additional dependency (no hapi-fhir-storage), using the FHIRPath engine already on the classpath - patch reuses the existing update code path, so authorization (reasonUpdateAllowed), validation, optimistic locking (If-Match), versioning and the Prefer header behave identically to an update - new PatchCommand plus PATCH dispatch in CommandFactoryImpl for bundle processing, patch branch added to the audit log - 'patch' interaction added to the CapabilityStatement - FhirWebserviceClient patch(...) and patchConditionaly(...) - unit tests for the patch engine, integration tests for REST, conditional and bundle patch including not-found and bad-request handling Co-Authored-By: Claude Opus 4.8 --- .../fhir/dao/command/AbstractCommandList.java | 11 + .../fhir/dao/command/CommandFactoryImpl.java | 23 ++ .../dsf/fhir/dao/command/PatchCommand.java | 319 +++++++++++++++ .../dev/dsf/fhir/help/ParameterConverter.java | 25 +- .../dev/dsf/fhir/help/ResponseGenerator.java | 18 + .../service/patch/FhirPatchException.java | 35 ++ .../service/patch/FhirPathPatchService.java | 45 +++ .../patch/FhirPathPatchServiceImpl.java | 381 ++++++++++++++++++ .../dsf/fhir/spring/config/HelperConfig.java | 13 +- .../impl/AbstractResourceServiceImpl.java | 14 + .../impl/ConformanceServiceImpl.java | 1 + .../jaxrs/AbstractResourceServiceJaxrs.java | 25 ++ .../dev/dsf/fhir/webservice/jaxrs/PATCH.java | 35 ++ .../secure/AbstractResourceServiceSecure.java | 64 +++ .../specification/BasicResourceService.java | 31 ++ .../integration/PatchIntegrationTest.java | 153 +++++++ .../patch/FhirPathPatchServiceImplTest.java | 229 +++++++++++ ...BasicFhirWebserviceCientWithRetryImpl.java | 14 + .../client/FhirWebserviceClientJersey.java | 63 +++ .../dsf/fhir/client/PreferReturnResource.java | 7 + 20 files changed, 1504 insertions(+), 2 deletions(-) create mode 100644 dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/dao/command/PatchCommand.java create mode 100644 dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/service/patch/FhirPatchException.java create mode 100644 dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/service/patch/FhirPathPatchService.java create mode 100644 dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/service/patch/FhirPathPatchServiceImpl.java create mode 100644 dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/jaxrs/PATCH.java create mode 100644 dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/integration/PatchIntegrationTest.java create mode 100644 dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/service/patch/FhirPathPatchServiceImplTest.java diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/dao/command/AbstractCommandList.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/dao/command/AbstractCommandList.java index b15ef23a0..3f71790a7 100644 --- a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/dao/command/AbstractCommandList.java +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/dao/command/AbstractCommandList.java @@ -94,6 +94,12 @@ else if (command instanceof UpdateCommand) command.getResourceTypeName(), command.getIdentity().getName(), command.getIndex(), resultOutcome, result.getResponse().getStatus()); } + else if (command instanceof PatchCommand) + { + audit.info("Patch of {} for identity '{}' via bundle at index {} {}, status: {}", + command.getResourceTypeName(), command.getIdentity().getName(), command.getIndex(), resultOutcome, + result.getResponse().getStatus()); + } else if (command instanceof ReadCommand) { audit.info("{} of {} for identity '{}' via bundle at index {} {}, status: {}", @@ -120,6 +126,11 @@ else if (command instanceof UpdateCommand) audit.info("Update of {} for identity '{}' via bundle at index {} aborted", command.getResourceTypeName(), command.getIdentity().getName(), command.getIndex()); } + else if (command instanceof PatchCommand) + { + audit.info("Patch of {} for identity '{}' via bundle at index {} aborted", command.getResourceTypeName(), + command.getIdentity().getName(), command.getIndex()); + } else if (command instanceof ReadCommand r) { audit.info("{} of {} for identity '{}' via bundle at index {} aborted", r.isSearch() ? "Search" : "Read", diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/dao/command/CommandFactoryImpl.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/dao/command/CommandFactoryImpl.java index 4dbbc48e3..d77e237f4 100755 --- a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/dao/command/CommandFactoryImpl.java +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/dao/command/CommandFactoryImpl.java @@ -29,6 +29,7 @@ import org.hl7.fhir.r4.model.Bundle; import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent; import org.hl7.fhir.r4.model.Bundle.HTTPVerb; +import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Resource; import org.hl7.fhir.r4.model.StructureDefinition; import org.slf4j.Logger; @@ -232,6 +233,24 @@ private Command delete(int index, Identity identity, PreferReturnType returnType "Request url " + entry.getRequest().getUrl() + " for method DELETE not supported"); } + // patch (FHIRPath Patch), standard and conditional + private Command patch(int index, Identity identity, PreferReturnType returnType, Bundle bundle, + BundleEntryComponent entry, Resource resource, boolean enableValidation) + { + if (entry.getRequest().getUrl() == null || entry.getRequest().getUrl().isBlank()) + throw new BadBundleException( + "Request url " + entry.getRequest().getUrl() + " for method PATCH not supported"); + + if (!(resource instanceof Parameters parameters)) + throw new BadBundleException("Request body for PATCH at index " + index + + " must be a Parameters resource (FHIRPath Patch), but was " + + (resource == null ? "null" : resource.getResourceType().name())); + + return new PatchCommand(index, identity, returnType, bundle, entry, serverBase, authorizationHelper, parameters, + daoProvider, exceptionHandler, parameterConverter, responseGenerator, referenceCleaner, eventGenerator, + defaultProfileProvider, enableValidation); + } + @Override public CommandList createCommands(Bundle bundle, Identity identity, PreferReturnType returnType, PreferHandlingType handlingType, boolean enableValidation) @@ -296,6 +315,10 @@ protected Stream createCommand(int index, Identity identity, PreferRetu put(index, identity, returnType, bundle, entry, entry.getResource(), enableValidation), index, identity, returnType, bundle, entry, entry.getResource(), HTTPVerb.PUT); + // no resolveReferences: the resource is the patch document (Parameters), not the target + case PATCH -> Stream.of( + patch(index, identity, returnType, bundle, entry, entry.getResource(), enableValidation)); + default -> throw new BadBundleException("Request method " + entry.getRequest().getMethod() + " at index " + index + " not supported with resource"); }; diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/dao/command/PatchCommand.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/dao/command/PatchCommand.java new file mode 100644 index 000000000..a75264406 --- /dev/null +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/dao/command/PatchCommand.java @@ -0,0 +1,319 @@ +/* + * Copyright 2018-2025 Heilbronn University of Applied Sciences + * + * 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. + */ +package dev.dsf.fhir.dao.command; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent; +import org.hl7.fhir.r4.model.Bundle.BundleEntryResponseComponent; +import org.hl7.fhir.r4.model.IdType; +import org.hl7.fhir.r4.model.OperationOutcome; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.CollectionUtils; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +import ca.uhn.fhir.validation.ValidationResult; +import dev.dsf.common.auth.conf.Identity; +import dev.dsf.fhir.dao.ResourceDao; +import dev.dsf.fhir.dao.exception.ResourceDeletedException; +import dev.dsf.fhir.dao.exception.ResourceNotFoundException; +import dev.dsf.fhir.dao.jdbc.LargeObjectManager; +import dev.dsf.fhir.dao.provider.DaoProvider; +import dev.dsf.fhir.event.EventGenerator; +import dev.dsf.fhir.event.EventHandler; +import dev.dsf.fhir.help.ExceptionHandler; +import dev.dsf.fhir.help.ParameterConverter; +import dev.dsf.fhir.help.ResponseGenerator; +import dev.dsf.fhir.prefer.PreferReturnType; +import dev.dsf.fhir.search.PageAndCount; +import dev.dsf.fhir.search.PartialResult; +import dev.dsf.fhir.search.SearchQuery; +import dev.dsf.fhir.search.SearchQueryParameterError; +import dev.dsf.fhir.service.DefaultProfileProvider; +import dev.dsf.fhir.service.ReferenceCleaner; +import dev.dsf.fhir.service.patch.FhirPatchException; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.EntityTag; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.ext.RuntimeDelegate; + +/** + * Applies a FHIRPath Patch ({@link Parameters} body) to an existing resource inside a transaction or batch bundle. The + * target resource type and id are taken from {@code Bundle.entry.request.url} (as with DELETE); the patch is applied to + * a copy of the current resource and the result is written through the same code path as an update, so authorization, + * validation, versioning and the Prefer header behave identically. + */ +public class PatchCommand extends AbstractCommand implements ModifyingCommand +{ + private static final Logger logger = LoggerFactory.getLogger(PatchCommand.class); + + private final Parameters patch; + private final DaoProvider daoProvider; + private final ExceptionHandler exceptionHandler; + private final ParameterConverter parameterConverter; + private final ResponseGenerator responseGenerator; + private final ReferenceCleaner referenceCleaner; + private final EventGenerator eventGenerator; + private final DefaultProfileProvider defaultProfileProvider; + private final boolean enableValidation; + + private final String resourceTypeName; + + private ResourceDao dao; + private Resource updatedResource; + private ValidationResult validationResult; + + public PatchCommand(int index, Identity identity, PreferReturnType returnType, Bundle bundle, + BundleEntryComponent entry, String serverBase, AuthorizationHelper authorizationHelper, Parameters patch, + DaoProvider daoProvider, ExceptionHandler exceptionHandler, ParameterConverter parameterConverter, + ResponseGenerator responseGenerator, ReferenceCleaner referenceCleaner, EventGenerator eventGenerator, + DefaultProfileProvider defaultProfileProvider, boolean enableValidation) + { + super(3, index, identity, returnType, bundle, entry, serverBase, authorizationHelper); + + this.patch = patch; + this.daoProvider = daoProvider; + this.exceptionHandler = exceptionHandler; + this.parameterConverter = parameterConverter; + this.responseGenerator = responseGenerator; + this.referenceCleaner = referenceCleaner; + this.eventGenerator = eventGenerator; + this.defaultProfileProvider = defaultProfileProvider; + this.enableValidation = enableValidation; + + this.resourceTypeName = parseResourceTypeName(entry); + + if (PreferReturnType.OPERATION_OUTCOME.equals(returnType) && !enableValidation) + throw new IllegalArgumentException("Return type 'operation outcome' not allowed if validation disabled"); + } + + private static String parseResourceTypeName(BundleEntryComponent entry) + { + String url = entry.getRequest().getUrl(); + if (url == null || url.isBlank()) + return null; + + List segments = UriComponentsBuilder.fromUriString(url).build().getPathSegments(); + return segments.isEmpty() ? null : segments.get(0); + } + + @Override + public String getResourceTypeName() + { + return resourceTypeName; + } + + @Override + public void execute(Map idTranslationTable, LargeObjectManager largeObjectManager, + Connection connection, ValidationHelper validationHelper) throws SQLException, WebApplicationException + { + UriComponents components = UriComponentsBuilder.fromUriString(entry.getRequest().getUrl()).build(); + List pathSegments = components.getPathSegments(); + + Resource dbResource; + if (pathSegments.size() == 2 && components.getQueryParams().isEmpty()) + dbResource = readById(connection, pathSegments.get(0), pathSegments.get(1)); + else if (pathSegments.size() == 1 && !components.getQueryParams().isEmpty()) + dbResource = readByCondition(connection, pathSegments.get(0), + parameterConverter.urlDecodeQueryParameters(components.getQueryParams())); + else + throw new WebApplicationException(responseGenerator + .badRequestPatch("Invalid patch request url '" + entry.getRequest().getUrl() + "'")); + + Resource patchedResource; + try + { + patchedResource = parameterConverter.applyPatch(dbResource, patch); + } + catch (FhirPatchException e) + { + throw new WebApplicationException(responseGenerator.badRequestPatch(e.getMessage())); + } + + // keep the id of the current resource; the patch is applied to a copy, so dbResource stays unmodified and can + // be used as the 'old' resource for the update authorization check + patchedResource.setIdElement(dbResource.getIdElement()); + + if (enableValidation) + { + defaultProfileProvider.setDefaultProfile(patchedResource); + validationResult = validationHelper.checkResourceValidForUpdate(identity, patchedResource); + } + + authorizationHelper.checkUpdateAllowed(index, connection, identity, dbResource, patchedResource); + + Optional ifMatch = Optional.ofNullable(entry.getRequest().getIfMatch()) + .flatMap(parameterConverter::toEntityTag).flatMap(parameterConverter::toVersion); + + updatedResource = exceptionHandler.handleSqlExAndResourceNotFoundExAndResouceVersionNonMatchEx(resourceTypeName, + () -> dao.updateWithTransaction(largeObjectManager, connection, patchedResource, ifMatch.orElse(null))); + } + + private Resource readById(Connection connection, String resourceTypeName, String id) + { + this.dao = getDao(resourceTypeName); + UUID uuid = parameterConverter.toUuid(resourceTypeName, id); + + return exceptionHandler + .handleSqlAndResourceDeletedException(serverBase, resourceTypeName, + () -> dao.readWithTransaction(connection, uuid)) + .orElseThrow(() -> new WebApplicationException(responseGenerator.notFound(id, resourceTypeName))); + } + + private Resource readByCondition(Connection connection, String resourceTypeName, + Map> queryParameters) + { + this.dao = getDao(resourceTypeName); + + if (Arrays.stream(SearchQuery.STANDARD_PARAMETERS).anyMatch(queryParameters::containsKey)) + { + logger.warn( + "Query contains parameter not applicable in this conditional patch context: '{}', parameters {} will be ignored", + UriComponentsBuilder.newInstance() + .replaceQueryParams(CollectionUtils.toMultiValueMap(queryParameters)).toUriString(), + Arrays.toString(SearchQuery.STANDARD_PARAMETERS)); + + queryParameters = queryParameters.entrySet().stream() + .filter(e -> Arrays.stream(SearchQuery.STANDARD_PARAMETERS).noneMatch(p -> p.equals(e.getKey()))) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + } + + SearchQuery query = dao.createSearchQueryWithoutUserFilter(PageAndCount.single()); + query.configureParameters(queryParameters); + + List unsupportedQueryParameters = query.getUnsupportedQueryParameters(); + if (!unsupportedQueryParameters.isEmpty()) + throw new WebApplicationException(responseGenerator.badRequest( + UriComponentsBuilder.newInstance() + .replaceQueryParams(CollectionUtils.toMultiValueMap(queryParameters)).toUriString(), + unsupportedQueryParameters)); + + PartialResult result = exceptionHandler + .handleSqlException(() -> dao.searchWithTransaction(connection, query)); + + String queryString = UriComponentsBuilder.newInstance() + .replaceQueryParams(CollectionUtils.toMultiValueMap(queryParameters)).toUriString(); + + if (result.getTotal() <= 0) + throw new WebApplicationException(responseGenerator.patchTargetNotFound(resourceTypeName, queryString)); + else if (result.getTotal() == 1) + return result.getPartialResult().get(0); + else + throw new WebApplicationException(responseGenerator.multipleExists(resourceTypeName, queryString)); + } + + private ResourceDao getDao(String resourceTypeName) + { + Optional> optDao = daoProvider.getDao(resourceTypeName); + + if (optDao.isEmpty()) + throw new WebApplicationException( + responseGenerator.resourceTypeNotSupportedByImplementation(index, resourceTypeName)); + + @SuppressWarnings("unchecked") + ResourceDao d = (ResourceDao) optDao.get(); + return d; + } + + @Override + public Optional postExecute(Connection connection, EventHandler eventHandler) + { + Resource updatedResourceWithResolvedReferences = latestOrErrorIfDeletedOrNotFound(connection, updatedResource); + + referenceCleaner.cleanLiteralReferences(updatedResourceWithResolvedReferences); + + try + { + eventHandler.handleEvent(eventGenerator.newResourceUpdatedEvent(updatedResourceWithResolvedReferences)); + } + catch (Exception e) + { + logger.debug("Error while handling resource updated event", e); + logger.warn("Error while handling resource updated event: {} - {}", e.getClass().getName(), e.getMessage()); + } + + IdType location = updatedResourceWithResolvedReferences.getIdElement().withServerBase(serverBase, + updatedResourceWithResolvedReferences.getResourceType().name()); + + BundleEntryComponent resultEntry = new BundleEntryComponent(); + resultEntry.setFullUrl(location.toVersionless().toString()); + + if (PreferReturnType.REPRESENTATION.equals(returnType)) + resultEntry.setResource(updatedResourceWithResolvedReferences); + else if (PreferReturnType.OPERATION_OUTCOME.equals(returnType)) + { + OperationOutcome outcome = responseGenerator.updated(location.toString(), + updatedResourceWithResolvedReferences); + + if (validationResult != null) + validationResult.populateOperationOutcome(outcome); + + resultEntry.getResponse().setOutcome(outcome); + } + + BundleEntryResponseComponent response = resultEntry.getResponse(); + response.setStatus(Status.OK.getStatusCode() + " " + Status.OK.getReasonPhrase()); + response.setLocation(location.getValue()); + response.setEtag(RuntimeDelegate.getInstance().createHeaderDelegate(EntityTag.class) + .toString(new EntityTag(updatedResourceWithResolvedReferences.getMeta().getVersionId(), true))); + response.setLastModified(updatedResourceWithResolvedReferences.getMeta().getLastUpdated()); + + return Optional.of(resultEntry); + } + + private Resource latestOrErrorIfDeletedOrNotFound(Connection connection, Resource resource) + { + try + { + return dao + .readWithTransaction(connection, + parameterConverter.toUuid(resource.getResourceType().name(), + resource.getIdElement().getIdPart())) + .orElseThrow(() -> new ResourceNotFoundException(resource.getIdElement().getIdPart())); + } + catch (ResourceNotFoundException | SQLException | ResourceDeletedException e) + { + logger.debug("Error while reading resource from db", e); + logger.warn("Error while reading resource from db: {} - {}", e.getClass().getName(), e.getMessage()); + + throw new RuntimeException(e); + } + } + + @Override + public LargeObjectManager createLargeObjectManager(Connection connection) + { + if ("Binary".equals(resourceTypeName)) + return daoProvider.getDao(resourceTypeName).map(d -> d.createLargeObjectManager(connection)) + .orElse(LargeObjectManager.NO_OP); + + return LargeObjectManager.NO_OP; + } +} diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/help/ParameterConverter.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/help/ParameterConverter.java index 4583e0baa..965f62826 100755 --- a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/help/ParameterConverter.java +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/help/ParameterConverter.java @@ -26,6 +26,8 @@ import java.util.UUID; import java.util.stream.Collectors; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,6 +35,7 @@ import dev.dsf.fhir.adapter.FhirAdapter; import dev.dsf.fhir.prefer.PreferHandlingType; import dev.dsf.fhir.prefer.PreferReturnType; +import dev.dsf.fhir.service.patch.FhirPathPatchService; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.EntityTag; import jakarta.ws.rs.core.HttpHeaders; @@ -58,10 +61,30 @@ public class ParameterConverter MediaType.APPLICATION_XML, MediaType.TEXT_XML); private final ExceptionHandler exceptionHandler; + private final FhirPathPatchService fhirPathPatchService; - public ParameterConverter(ExceptionHandler exceptionHandler) + public ParameterConverter(ExceptionHandler exceptionHandler, FhirPathPatchService fhirPathPatchService) { this.exceptionHandler = exceptionHandler; + this.fhirPathPatchService = fhirPathPatchService; + } + + /** + * Applies the given FHIRPath Patch to a copy of the given resource. + * + * @param + * the resource type + * @param resource + * the resource to patch, not null + * @param patch + * the FHIRPath Patch as a {@link Parameters} resource, not null + * @return a patched copy of the given resource + * @throws dev.dsf.fhir.service.patch.FhirPatchException + * if the patch is invalid or can not be applied + */ + public R applyPatch(R resource, Parameters patch) + { + return fhirPathPatchService.apply(resource, patch); } /** diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/help/ResponseGenerator.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/help/ResponseGenerator.java index 4dfa782c3..91f3ea36c 100755 --- a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/help/ResponseGenerator.java +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/help/ResponseGenerator.java @@ -957,6 +957,24 @@ public Response notFound(String id, String resourceTypeName) return Response.status(Status.NOT_FOUND).entity(outcome).build(); } + public Response badRequestPatch(String errorMessage) + { + logger.warn("Bad patch request: {}", errorMessage); + + OperationOutcome outcome = createOutcome(IssueSeverity.ERROR, IssueType.PROCESSING, + "Bad patch request: " + errorMessage); + return Response.status(Status.BAD_REQUEST).entity(outcome).build(); + } + + public Response patchTargetNotFound(String resourceTypeName, String queryParameters) + { + logger.warn("No {} matched conditional patch criteria '{}'", resourceTypeName, queryParameters); + + OperationOutcome outcome = createOutcome(IssueSeverity.ERROR, IssueType.NOTFOUND, + "No " + resourceTypeName + " matched conditional patch criteria '" + queryParameters + "'"); + return Response.status(Status.NOT_FOUND).entity(outcome).build(); + } + public Response forbiddenNotValid(String operation, Identity identity, String resourceType, ValidationResult validationResult) { diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/service/patch/FhirPatchException.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/service/patch/FhirPatchException.java new file mode 100644 index 000000000..4c35b9c2b --- /dev/null +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/service/patch/FhirPatchException.java @@ -0,0 +1,35 @@ +/* + * Copyright 2018-2025 Heilbronn University of Applied Sciences + * + * 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. + */ +package dev.dsf.fhir.service.patch; + +/** + * Thrown when a FHIRPath Patch is syntactically invalid or can not be applied to the target resource. Callers are + * expected to translate this into an HTTP 400 Bad Request with an OperationOutcome. + */ +public class FhirPatchException extends RuntimeException +{ + private static final long serialVersionUID = 1L; + + public FhirPatchException(String message) + { + super(message); + } + + public FhirPatchException(String message, Throwable cause) + { + super(message, cause); + } +} diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/service/patch/FhirPathPatchService.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/service/patch/FhirPathPatchService.java new file mode 100644 index 000000000..41e215d66 --- /dev/null +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/service/patch/FhirPathPatchService.java @@ -0,0 +1,45 @@ +/* + * Copyright 2018-2025 Heilbronn University of Applied Sciences + * + * 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. + */ +package dev.dsf.fhir.service.patch; + +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Resource; + +/** + * Applies a FHIRPath Patch (a {@link Parameters} resource + * containing one or more operation parameters) to a FHIR resource. + *

+ * Only the FHIRPath Patch format is supported (JSON Patch / XML Patch are intentionally not implemented to keep the DSF + * dependency footprint minimal). The supported operation types are add, insert, + * delete, replace and move. + */ +public interface FhirPathPatchService +{ + /** + * Applies the given patch to a copy of the given resource. The input resource is not modified. + * + * @param + * the resource type + * @param resource + * the resource to patch, not null + * @param patch + * the FHIRPath Patch as a {@link Parameters} resource, not null + * @return a patched copy of the given resource, never null + * @throws FhirPatchException + * if the patch is syntactically invalid or can not be applied to the given resource + */ + R apply(R resource, Parameters patch); +} diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/service/patch/FhirPathPatchServiceImpl.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/service/patch/FhirPathPatchServiceImpl.java new file mode 100644 index 000000000..478178c5d --- /dev/null +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/service/patch/FhirPathPatchServiceImpl.java @@ -0,0 +1,381 @@ +/* + * Copyright 2018-2025 Heilbronn University of Applied Sciences + * + * 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. + */ +package dev.dsf.fhir.service.patch; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.hl7.fhir.instance.model.api.IBase; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; +import org.hl7.fhir.r4.model.Resource; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.util.Assert; + +import ca.uhn.fhir.context.BaseRuntimeChildDefinition; +import ca.uhn.fhir.context.BaseRuntimeElementCompositeDefinition; +import ca.uhn.fhir.context.BaseRuntimeElementDefinition; +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.fhirpath.IFhirPath; + +/** + * FHIRPath Patch engine for FHIR R4, implemented on top of the HAPI runtime model ({@link FhirContext}, + * {@link IFhirPath}) so that no hapi-fhir-storage dependency is required. + *

+ * The parent element of the targeted path is located by evaluating the path prefix with the FHIRPath engine; the final + * path segment (element name and optional [index]) is then applied via the runtime child definition of the + * parent. This mirrors the semantics defined at + * https://www.hl7.org/fhir/R4/fhirpatch.html. + */ +public class FhirPathPatchServiceImpl implements FhirPathPatchService, InitializingBean +{ + private static final String PARAMETER_NAME_OPERATION = "operation"; + private static final String PART_NAME_TYPE = "type"; + private static final String PART_NAME_PATH = "path"; + private static final String PART_NAME_NAME = "name"; + private static final String PART_NAME_VALUE = "value"; + private static final String PART_NAME_INDEX = "index"; + private static final String PART_NAME_SOURCE = "source"; + private static final String PART_NAME_DESTINATION = "destination"; + + private final FhirContext fhirContext; + + public FhirPathPatchServiceImpl(FhirContext fhirContext) + { + this.fhirContext = fhirContext; + } + + @Override + public void afterPropertiesSet() throws Exception + { + Assert.notNull(fhirContext, "fhirContext"); + } + + @Override + @SuppressWarnings("unchecked") + public R apply(R resource, Parameters patch) + { + Objects.requireNonNull(resource, "resource"); + Objects.requireNonNull(patch, "patch"); + + R target = (R) resource.copy(); + + List operations = patch.getParameter().stream() + .filter(p -> PARAMETER_NAME_OPERATION.equals(p.getName())).toList(); + + if (operations.isEmpty()) + throw new FhirPatchException("Patch Parameters contains no 'operation' parameter"); + + IFhirPath fhirPath = fhirContext.newFhirPath(); + for (int i = 0; i < operations.size(); i++) + applyOperation(fhirPath, target, operations.get(i), i); + + return target; + } + + private void applyOperation(IFhirPath fhirPath, Resource target, ParametersParameterComponent operation, int index) + { + String type = requiredString(operation, PART_NAME_TYPE, index); + String path = requiredString(operation, PART_NAME_PATH, index); + + switch (type) + { + case "add" -> handleAdd(fhirPath, target, operation, path, index); + case "insert" -> handleInsert(fhirPath, target, operation, path, index); + case "delete" -> handleDelete(fhirPath, target, path, index); + case "replace" -> handleReplace(fhirPath, target, operation, path, index); + case "move" -> handleMove(fhirPath, target, operation, path, index); + default -> throw new FhirPatchException( + "Unsupported patch operation type '" + type + "' at operation index " + index); + } + } + + private void handleAdd(IFhirPath fhirPath, Resource target, ParametersParameterComponent operation, String path, + int index) + { + // for 'add' the path points to the element to add to, 'name' is the child element to add + String name = requiredString(operation, PART_NAME_NAME, index); + IBase parent = singleParent(fhirPath, target, path, index); + + BaseRuntimeChildDefinition child = childDefinition(parent, name, index); + IBase value = value(operation, child, name, index); + child.getMutator().addValue(parent, value); + } + + private void handleInsert(IFhirPath fhirPath, Resource target, ParametersParameterComponent operation, String path, + int index) + { + PathSegment segment = splitPath(path, index); + IBase parent = singleParent(fhirPath, target, segment.parentPath(), index); + + BaseRuntimeChildDefinition child = childDefinition(parent, segment.name(), index); + List values = child.getAccessor().getValues(parent); + int insertIndex = requiredInteger(operation, PART_NAME_INDEX, index); + if (insertIndex < 0 || insertIndex > values.size()) + throw new FhirPatchException("Insert index " + insertIndex + " out of bounds (list size " + values.size() + + ") at operation index " + index); + + IBase value = value(operation, child, segment.name(), index); + values.add(insertIndex, value); + } + + private void handleDelete(IFhirPath fhirPath, Resource target, String path, int index) + { + PathSegment segment = splitPath(path, index); + + // delete is idempotent: a path that resolves to nothing is a no-op + Optional parent = optionalSingleParent(fhirPath, target, segment.parentPath(), index); + if (parent.isEmpty()) + return; + + BaseRuntimeChildDefinition child = childDefinition(parent.get(), segment.name(), index); + if (segment.index().isPresent()) + { + List values = child.getAccessor().getValues(parent.get()); + int i = segment.index().get(); + if (i >= 0 && i < values.size()) + values.remove(i); + } + else + child.getMutator().setValue(parent.get(), null); + } + + private void handleReplace(IFhirPath fhirPath, Resource target, ParametersParameterComponent operation, String path, + int index) + { + PathSegment segment = splitPath(path, index); + IBase parent = singleParent(fhirPath, target, segment.parentPath(), index); + + BaseRuntimeChildDefinition child = childDefinition(parent, segment.name(), index); + IBase value = value(operation, child, segment.name(), index); + + if (segment.index().isPresent()) + { + List values = child.getAccessor().getValues(parent); + int i = segment.index().get(); + if (i < 0 || i >= values.size()) + throw new FhirPatchException("Replace index " + i + " out of bounds (list size " + values.size() + + ") at operation index " + index); + values.set(i, value); + } + else + child.getMutator().setValue(parent, value); + } + + private void handleMove(IFhirPath fhirPath, Resource target, ParametersParameterComponent operation, String path, + int index) + { + PathSegment segment = splitPath(path, index); + IBase parent = singleParent(fhirPath, target, segment.parentPath(), index); + + BaseRuntimeChildDefinition child = childDefinition(parent, segment.name(), index); + List values = child.getAccessor().getValues(parent); + + int source = requiredInteger(operation, PART_NAME_SOURCE, index); + int destination = requiredInteger(operation, PART_NAME_DESTINATION, index); + if (source < 0 || source >= values.size()) + throw new FhirPatchException("Move source index " + source + " out of bounds (list size " + values.size() + + ") at operation index " + index); + if (destination < 0 || destination >= values.size()) + throw new FhirPatchException("Move destination index " + destination + " out of bounds (list size " + + values.size() + ") at operation index " + index); + + IBase moved = values.remove(source); + values.add(destination, moved); + } + + private IBase singleParent(IFhirPath fhirPath, Resource target, String path, int operationIndex) + { + return optionalSingleParent(fhirPath, target, path, operationIndex).orElseThrow(() -> new FhirPatchException( + "Path '" + path + "' does not resolve to an element at operation index " + operationIndex)); + } + + private Optional optionalSingleParent(IFhirPath fhirPath, Resource target, String path, int operationIndex) + { + List parents; + try + { + parents = fhirPath.evaluate(target, path, IBase.class); + } + catch (Exception e) + { + throw new FhirPatchException("Unable to evaluate path '" + path + "' at operation index " + operationIndex + + ": " + e.getMessage(), e); + } + + if (parents.isEmpty()) + return Optional.empty(); + if (parents.size() > 1) + throw new FhirPatchException("Path '" + path + "' resolves to " + parents.size() + + " elements, expected exactly one at operation index " + operationIndex); + + return Optional.of(parents.get(0)); + } + + private BaseRuntimeChildDefinition childDefinition(IBase parent, String name, int operationIndex) + { + BaseRuntimeElementDefinition elementDefinition = fhirContext.getElementDefinition(parent.getClass()); + if (!(elementDefinition instanceof BaseRuntimeElementCompositeDefinition composite)) + throw new FhirPatchException("Element '" + parent.fhirType() + + "' is not a composite type and has no child '" + name + "' at operation index " + operationIndex); + + BaseRuntimeChildDefinition child = composite.getChildByName(name); + if (child == null) + // choice elements (value[x]) are registered under their concrete names, retry with the [x] suffix + child = composite.getChildByName(name + "[x]"); + if (child == null) + throw new FhirPatchException("Element '" + parent.fhirType() + "' has no child '" + name + + "' at operation index " + operationIndex); + + return child; + } + + private IBase value(ParametersParameterComponent operation, BaseRuntimeChildDefinition child, String name, + int operationIndex) + { + ParametersParameterComponent valuePart = part(operation, PART_NAME_VALUE) + .orElseThrow(() -> new FhirPatchException("Missing 'value' part at operation index " + operationIndex)); + + BaseRuntimeElementDefinition elementDefinition = child.getChildByName(name); + if (elementDefinition == null && !child.getValidChildNames().isEmpty()) + elementDefinition = child.getChildByName(child.getValidChildNames().iterator().next()); + + return buildValue(valuePart, elementDefinition, operationIndex); + } + + private IBase buildValue(ParametersParameterComponent valuePart, BaseRuntimeElementDefinition elementDefinition, + int operationIndex) + { + if (valuePart.hasValue()) + return valuePart.getValue(); + + if (!valuePart.hasPart()) + throw new FhirPatchException( + "'value' part has neither a value nor sub-parts at operation index " + operationIndex); + + if (!(elementDefinition instanceof BaseRuntimeElementCompositeDefinition composite)) + throw new FhirPatchException( + "Complex 'value' provided for non-composite element at operation index " + operationIndex); + + IBase instance = elementDefinition.newInstance(); + for (ParametersParameterComponent subPart : valuePart.getPart()) + { + BaseRuntimeChildDefinition subChild = composite.getChildByName(subPart.getName()); + if (subChild == null) + throw new FhirPatchException("Complex 'value' has no child '" + subPart.getName() + + "' at operation index " + operationIndex); + + IBase subValue = buildValue(subPart, subChild.getChildByName(subPart.getName()), operationIndex); + subChild.getMutator().addValue(instance, subValue); + } + return instance; + } + + private Optional part(ParametersParameterComponent operation, String name) + { + return operation.getPart().stream().filter(p -> name.equals(p.getName())).findFirst(); + } + + private String requiredString(ParametersParameterComponent operation, String partName, int operationIndex) + { + return part(operation, partName).filter(ParametersParameterComponent::hasValue).map(p -> p.getValue()) + .map(v -> v.primitiveValue()).filter(s -> s != null && !s.isBlank()) + .orElseThrow(() -> new FhirPatchException( + "Missing or empty '" + partName + "' part at operation index " + operationIndex)); + } + + private int requiredInteger(ParametersParameterComponent operation, String partName, int operationIndex) + { + String value = requiredString(operation, partName, operationIndex); + try + { + return Integer.parseInt(value); + } + catch (NumberFormatException e) + { + throw new FhirPatchException( + "'" + partName + "' part is not an integer at operation index " + operationIndex, e); + } + } + + /** + * Splits a FHIRPath into the parent path and the final segment (element name plus optional [index]). + * The split is done at the last top-level . (dots inside brackets or parentheses are ignored). + */ + PathSegment splitPath(String path, int operationIndex) + { + int depthBracket = 0; + int depthParen = 0; + int splitAt = -1; + for (int i = 0; i < path.length(); i++) + { + char c = path.charAt(i); + switch (c) + { + case '[' -> depthBracket++; + case ']' -> depthBracket--; + case '(' -> depthParen++; + case ')' -> depthParen--; + case '.' -> { + if (depthBracket == 0 && depthParen == 0) + splitAt = i; + } + default -> { + // no-op + } + } + } + + if (splitAt < 0) + throw new FhirPatchException( + "Path '" + path + "' has no parent element at operation index " + operationIndex); + + String parentPath = path.substring(0, splitAt); + String segment = path.substring(splitAt + 1); + + String name = segment; + Optional segmentIndex = Optional.empty(); + int bracket = segment.indexOf('['); + if (bracket >= 0) + { + if (!segment.endsWith("]")) + throw new FhirPatchException( + "Malformed final path segment '" + segment + "' at operation index " + operationIndex); + name = segment.substring(0, bracket); + String indexString = segment.substring(bracket + 1, segment.length() - 1); + try + { + segmentIndex = Optional.of(Integer.parseInt(indexString)); + } + catch (NumberFormatException e) + { + throw new FhirPatchException( + "Malformed index in path segment '" + segment + "' at operation index " + operationIndex, e); + } + } + + if (name.isBlank() || !name.chars().allMatch(c -> Character.isLetterOrDigit(c) || c == '_')) + throw new FhirPatchException("Unsupported final path segment '" + segment + + "', expected a plain element name at operation index " + operationIndex); + + return new PathSegment(parentPath, name, segmentIndex); + } + + record PathSegment(String parentPath, String name, Optional index) + { + } +} diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/spring/config/HelperConfig.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/spring/config/HelperConfig.java index 045ce2c3e..2374686e3 100755 --- a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/spring/config/HelperConfig.java +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/spring/config/HelperConfig.java @@ -22,6 +22,8 @@ import dev.dsf.fhir.help.ExceptionHandler; import dev.dsf.fhir.help.ParameterConverter; import dev.dsf.fhir.help.ResponseGenerator; +import dev.dsf.fhir.service.patch.FhirPathPatchService; +import dev.dsf.fhir.service.patch.FhirPathPatchServiceImpl; @Configuration public class HelperConfig @@ -29,6 +31,9 @@ public class HelperConfig @Autowired private PropertiesConfig propertiesConfig; + @Autowired + private FhirConfig fhirConfig; + @Bean public ExceptionHandler exceptionHandler() { @@ -41,9 +46,15 @@ public ResponseGenerator responseGenerator() return new ResponseGenerator(propertiesConfig.getDsfServerBaseUrl()); } + @Bean + public FhirPathPatchService fhirPathPatchService() + { + return new FhirPathPatchServiceImpl(fhirConfig.fhirContext()); + } + @Bean public ParameterConverter parameterConverter() { - return new ParameterConverter(exceptionHandler()); + return new ParameterConverter(exceptionHandler(), fhirPathPatchService()); } } diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/impl/AbstractResourceServiceImpl.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/impl/AbstractResourceServiceImpl.java index f14270501..e370f987d 100755 --- a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/impl/AbstractResourceServiceImpl.java +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/impl/AbstractResourceServiceImpl.java @@ -41,6 +41,7 @@ import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent; import org.hl7.fhir.r4.model.IdType; import org.hl7.fhir.r4.model.OperationOutcome; +import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Resource; import org.postgresql.util.PSQLState; import org.slf4j.Logger; @@ -660,6 +661,19 @@ public Response update(R resource, UriInfo uri, HttpHeaders headers) throw new UnsupportedOperationException("Implemented and delegated by security layer"); } + @Override + public Response patch(String id, Parameters patch, UriInfo uri, HttpHeaders headers) + { + // the security layer applies the patch to the current resource and delegates to update + throw new UnsupportedOperationException("Implemented and delegated by security layer"); + } + + @Override + public Response patch(Parameters patch, UriInfo uri, HttpHeaders headers) + { + throw new UnsupportedOperationException("Implemented and delegated by security layer"); + } + @Override public Response delete(String id, UriInfo uri, HttpHeaders headers) { diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/impl/ConformanceServiceImpl.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/impl/ConformanceServiceImpl.java index 73caa5a4c..1aa506066 100755 --- a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/impl/ConformanceServiceImpl.java +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/impl/ConformanceServiceImpl.java @@ -364,6 +364,7 @@ private CapabilityStatement createCapabilityStatement() r.addInteraction().setCode(TypeRestfulInteraction.READ); r.addInteraction().setCode(TypeRestfulInteraction.VREAD); r.addInteraction().setCode(TypeRestfulInteraction.UPDATE); + r.addInteraction().setCode(TypeRestfulInteraction.PATCH); r.addInteraction().setCode(TypeRestfulInteraction.DELETE); r.addInteraction().setCode(TypeRestfulInteraction.SEARCHTYPE); diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/jaxrs/AbstractResourceServiceJaxrs.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/jaxrs/AbstractResourceServiceJaxrs.java index febc5beff..5df09c12c 100755 --- a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/jaxrs/AbstractResourceServiceJaxrs.java +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/jaxrs/AbstractResourceServiceJaxrs.java @@ -15,6 +15,7 @@ */ package dev.dsf.fhir.webservice.jaxrs; +import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Resource; import org.springframework.beans.factory.InitializingBean; @@ -117,6 +118,30 @@ public Response update(R resource, @Context UriInfo uri, @Context HttpHeaders he return delegate.update(resource, uri, headers); } + @PATCH + @Path("/{id}") + @Consumes({ Constants.CT_FHIR_XML, Constants.CT_FHIR_XML_NEW, MediaType.APPLICATION_XML, Constants.CT_FHIR_JSON, + Constants.CT_FHIR_JSON_NEW, MediaType.APPLICATION_JSON }) + @Produces({ Constants.CT_FHIR_XML, Constants.CT_FHIR_XML_NEW, MediaType.APPLICATION_XML, Constants.CT_FHIR_JSON, + Constants.CT_FHIR_JSON_NEW, MediaType.APPLICATION_JSON, MediaType.TEXT_HTML }) + @Override + public Response patch(@PathParam("id") String id, Parameters patch, @Context UriInfo uri, + @Context HttpHeaders headers) + { + return delegate.patch(id, patch, uri, headers); + } + + @PATCH + @Consumes({ Constants.CT_FHIR_XML, Constants.CT_FHIR_XML_NEW, MediaType.APPLICATION_XML, Constants.CT_FHIR_JSON, + Constants.CT_FHIR_JSON_NEW, MediaType.APPLICATION_JSON }) + @Produces({ Constants.CT_FHIR_XML, Constants.CT_FHIR_XML_NEW, MediaType.APPLICATION_XML, Constants.CT_FHIR_JSON, + Constants.CT_FHIR_JSON_NEW, MediaType.APPLICATION_JSON, MediaType.TEXT_HTML }) + @Override + public Response patch(Parameters patch, @Context UriInfo uri, @Context HttpHeaders headers) + { + return delegate.patch(patch, uri, headers); + } + @DELETE @Path("/{id}") @Consumes({ Constants.CT_FHIR_XML, Constants.CT_FHIR_XML_NEW, MediaType.APPLICATION_XML, Constants.CT_FHIR_JSON, diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/jaxrs/PATCH.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/jaxrs/PATCH.java new file mode 100644 index 000000000..29bf841f9 --- /dev/null +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/jaxrs/PATCH.java @@ -0,0 +1,35 @@ +/* + * Copyright 2018-2025 Heilbronn University of Applied Sciences + * + * 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. + */ +package dev.dsf.fhir.webservice.jaxrs; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.ws.rs.HttpMethod; + +/** + * JAX-RS binding annotation for the HTTP PATCH method. Jakarta RESTful Web Services does not ship a + * PATCH annotation (unlike {@link jakarta.ws.rs.GET}, {@link jakarta.ws.rs.PUT} etc.), so it is declared + * here analogously. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@HttpMethod("PATCH") +public @interface PATCH +{ +} diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/secure/AbstractResourceServiceSecure.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/secure/AbstractResourceServiceSecure.java index c9e4a491b..c9cb2f442 100755 --- a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/secure/AbstractResourceServiceSecure.java +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/secure/AbstractResourceServiceSecure.java @@ -28,6 +28,7 @@ import org.hl7.fhir.r4.model.IdType; import org.hl7.fhir.r4.model.OperationOutcome; +import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -54,6 +55,7 @@ import dev.dsf.fhir.service.ReferenceResolver; import dev.dsf.fhir.service.ResourceReference; import dev.dsf.fhir.service.ResourceReference.ReferenceType; +import dev.dsf.fhir.service.patch.FhirPatchException; import dev.dsf.fhir.validation.ResourceValidator; import dev.dsf.fhir.validation.ValidationRules; import dev.dsf.fhir.webservice.specification.BasicResourceService; @@ -448,6 +450,68 @@ else if (!updated.hasEntity() } } + @Override + public Response patch(String id, Parameters patch, UriInfo uri, HttpHeaders headers) + { + Optional dbResource = exceptionHandler.handleSqlAndResourceDeletedException(serverBase, resourceTypeName, + () -> dao.read(parameterConverter.toUuid(resourceTypeName, id))); + + if (dbResource.isEmpty()) + { + audit.info("Patch of non existing {}/{} denied for identity '{}'", resourceTypeName, id, + getCurrentIdentity().getName()); + return responseGenerator.notFound(id, resourceTypeName); + } + + R oldResource = referenceCleaner.cleanLiteralReferences(dbResource.get()); + R patchedResource; + try + { + patchedResource = parameterConverter.applyPatch(oldResource, patch); + } + catch (FhirPatchException e) + { + audit.info("Patch of {}/{} denied for identity '{}', invalid patch: {}", resourceTypeName, id, + getCurrentIdentity().getName(), e.getMessage()); + return responseGenerator.badRequestPatch(e.getMessage()); + } + + // patch is applied to a copy of the current resource; keep its id and let the update path handle + // authorization (as an update), If-Match, versioning and the Prefer header + patchedResource.setIdElement(oldResource.getIdElement()); + return update(id, patchedResource, uri, headers, oldResource); + } + + @Override + public Response patch(Parameters patch, UriInfo uri, HttpHeaders headers) + { + Map> queryParameters = uri.getQueryParameters(); + PartialResult result = getExisting(queryParameters); + + // No matches: 404 Not Found (conditional patch does not create) + if (result.getTotal() <= 0) + { + audit.info("Conditional patch of {} denied for identity '{}', no match", resourceTypeName, + getCurrentIdentity().getName()); + return responseGenerator.patchTargetNotFound(resourceTypeName, UriComponentsBuilder.newInstance() + .replaceQueryParams(CollectionUtils.toMultiValueMap(queryParameters)).toUriString()); + } + + // One match: patch the matching resource (delegates to the standard patch which re-reads by id) + else if (result.getTotal() == 1) + return patch(result.getPartialResult().get(0).getIdElement().getIdPart(), patch, uri, headers); + + // Multiple matches: 412 Precondition Failed, criteria not selective enough + else + { + audit.info( + "Conditional patch of {} denied for identity '{}', criteria not selective enough, multiple matches", + resourceTypeName, getCurrentIdentity().getName()); + return responseGenerator.multipleExists(resourceTypeName, UriComponentsBuilder.newInstance() + .replaceQueryParams(CollectionUtils.toMultiValueMap(queryParameters)).toUriString()); + } + } + @Override public Response update(R resource, UriInfo uri, HttpHeaders headers) { diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/specification/BasicResourceService.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/specification/BasicResourceService.java index 8fb54abed..c9e418047 100755 --- a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/specification/BasicResourceService.java +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/webservice/specification/BasicResourceService.java @@ -15,6 +15,7 @@ */ package dev.dsf.fhir.webservice.specification; +import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Resource; import dev.dsf.fhir.webservice.base.BasicService; @@ -102,6 +103,36 @@ public interface BasicResourceService extends BasicService */ Response update(R resource, UriInfo uri, HttpHeaders headers); + /** + * standard patch (FHIRPath Patch) + * + * @param id + * not null + * @param patch + * the FHIRPath Patch as a {@link Parameters} resource, not null + * @param uri + * not null + * @param headers + * not null + * @return {@link Response} defined in + * https://www.hl7.org/fhir/http.html#patch + */ + Response patch(String id, Parameters patch, UriInfo uri, HttpHeaders headers); + + /** + * conditional patch (FHIRPath Patch) + * + * @param patch + * the FHIRPath Patch as a {@link Parameters} resource, not null + * @param uri + * not null + * @param headers + * not null + * @return {@link Response} defined in + * https://www.hl7.org/fhir/http.html#patch + */ + Response patch(Parameters patch, UriInfo uri, HttpHeaders headers); + /** * standard delete * diff --git a/dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/integration/PatchIntegrationTest.java b/dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/integration/PatchIntegrationTest.java new file mode 100644 index 000000000..3da7e2682 --- /dev/null +++ b/dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/integration/PatchIntegrationTest.java @@ -0,0 +1,153 @@ +/* + * Copyright 2018-2025 Heilbronn University of Applied Sciences + * + * 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. + */ +package dev.dsf.fhir.integration; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent; +import org.hl7.fhir.r4.model.Bundle.BundleType; +import org.hl7.fhir.r4.model.Bundle.HTTPVerb; +import org.hl7.fhir.r4.model.CodeType; +import org.hl7.fhir.r4.model.Endpoint; +import org.hl7.fhir.r4.model.Endpoint.EndpointStatus; +import org.hl7.fhir.r4.model.Organization; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; +import org.hl7.fhir.r4.model.StringType; +import org.junit.Test; + +import dev.dsf.fhir.authentication.OrganizationProvider; + +public class PatchIntegrationTest extends AbstractIntegrationTest +{ + private Endpoint createEndpoint(String identifierValue, String name) + { + OrganizationProvider organizationProvider = getSpringWebApplicationContext() + .getBean(OrganizationProvider.class); + Organization localOrganization = organizationProvider.getLocalOrganization().get(); + + Endpoint endpoint = new Endpoint(); + endpoint.addIdentifier().setSystem("http://dsf.dev/sid/endpoint-identifier").setValue(identifierValue); + endpoint.setStatus(EndpointStatus.ACTIVE); + endpoint.getConnectionType().setSystem("http://terminology.hl7.org/CodeSystem/endpoint-connection-type") + .setCode("hl7-fhir-rest"); + endpoint.setName(name); + endpoint.getPayloadTypeFirstRep().getCodingFirstRep().setSystem("http://hl7.org/fhir/resource-types") + .setCode("Task"); + endpoint.addPayloadMimeType("application/fhir+json"); + endpoint.addPayloadMimeType("application/fhir+xml"); + endpoint.setAddress("https://foo-bar-baz.test.bla-bla.de/fhir"); + endpoint.getManagingOrganization() + .setReferenceElement(localOrganization.getIdElement().toUnqualifiedVersionless()); + + getReadAccessHelper().addLocal(endpoint); + return endpoint; + } + + private static Parameters replacePatch(String path, String value) + { + Parameters patch = new Parameters(); + ParametersParameterComponent op = patch.addParameter().setName("operation"); + op.addPart().setName("type").setValue(new CodeType("replace")); + op.addPart().setName("path").setValue(new StringType(path)); + op.addPart().setName("value").setValue(new StringType(value)); + return patch; + } + + @Test + public void testPatchEndpointReplaceName() throws Exception + { + Endpoint created = getWebserviceClient().create(createEndpoint("patch.test.1", "Original Name")); + assertEquals("Original Name", created.getName()); + assertEquals("1", created.getIdElement().getVersionIdPart()); + + Endpoint patched = getWebserviceClient().patch(Endpoint.class, created.getIdElement().getIdPart(), + replacePatch("Endpoint.name", "Patched Name")); + + assertNotNull(patched); + assertEquals("Patched Name", patched.getName()); + assertEquals("2", patched.getIdElement().getVersionIdPart()); + + // other fields must be unchanged + assertEquals(EndpointStatus.ACTIVE, patched.getStatus()); + assertEquals("patch.test.1", patched.getIdentifierFirstRep().getValue()); + + // verify persisted + Endpoint read = getWebserviceClient().read(Endpoint.class, created.getIdElement().getIdPart()); + assertEquals("Patched Name", read.getName()); + assertEquals("2", read.getIdElement().getVersionIdPart()); + } + + @Test + public void testConditionalPatchEndpointByIdentifier() throws Exception + { + getWebserviceClient().create(createEndpoint("patch.test.conditional", "Original Name")); + + Endpoint patched = getWebserviceClient().patchConditionaly(Endpoint.class, + replacePatch("Endpoint.name", "Conditionally Patched"), + Map.of("identifier", List.of("http://dsf.dev/sid/endpoint-identifier|patch.test.conditional"))); + + assertNotNull(patched); + assertEquals("Conditionally Patched", patched.getName()); + assertEquals("2", patched.getIdElement().getVersionIdPart()); + } + + @Test + public void testPatchEndpointViaTransactionBundle() throws Exception + { + Endpoint created = getWebserviceClient().create(createEndpoint("patch.test.bundle", "Original Name")); + + Bundle bundle = new Bundle(); + bundle.setType(BundleType.TRANSACTION); + BundleEntryComponent entry = bundle.addEntry(); + entry.setResource(replacePatch("Endpoint.name", "Bundle Patched Name")); + entry.getRequest().setMethod(HTTPVerb.PATCH).setUrl("Endpoint/" + created.getIdElement().getIdPart()); + + Bundle response = getWebserviceClient().postBundle(bundle); + + assertNotNull(response); + assertEquals(BundleType.TRANSACTIONRESPONSE, response.getType()); + assertEquals(1, response.getEntry().size()); + assertTrue(response.getEntryFirstRep().getResponse().getStatus().startsWith("200")); + + Endpoint read = getWebserviceClient().read(Endpoint.class, created.getIdElement().getIdPart()); + assertEquals("Bundle Patched Name", read.getName()); + assertEquals("2", read.getIdElement().getVersionIdPart()); + } + + @Test + public void testPatchNonExistentEndpointReturnsNotFound() throws Exception + { + expectNotFound(() -> getWebserviceClient().patch(Endpoint.class, UUID.randomUUID().toString(), + replacePatch("Endpoint.name", "does not matter"))); + } + + @Test + public void testPatchWithUnknownElementReturnsBadRequest() throws Exception + { + Endpoint created = getWebserviceClient().create(createEndpoint("patch.test.badrequest", "Original Name")); + + expectBadRequest(() -> getWebserviceClient().patch(Endpoint.class, created.getIdElement().getIdPart(), + replacePatch("Endpoint.doesNotExist", "x"))); + } +} diff --git a/dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/service/patch/FhirPathPatchServiceImplTest.java b/dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/service/patch/FhirPathPatchServiceImplTest.java new file mode 100644 index 000000000..d07ee7738 --- /dev/null +++ b/dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/service/patch/FhirPathPatchServiceImplTest.java @@ -0,0 +1,229 @@ +/* + * Copyright 2018-2025 Heilbronn University of Applied Sciences + * + * 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. + */ +package dev.dsf.fhir.service.patch; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.List; + +import org.hl7.fhir.r4.model.BooleanType; +import org.hl7.fhir.r4.model.CodeType; +import org.hl7.fhir.r4.model.DateType; +import org.hl7.fhir.r4.model.HumanName; +import org.hl7.fhir.r4.model.Identifier; +import org.hl7.fhir.r4.model.IntegerType; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; +import org.hl7.fhir.r4.model.Patient; +import org.hl7.fhir.r4.model.StringType; +import org.hl7.fhir.r4.model.Type; +import org.junit.Test; + +import ca.uhn.fhir.context.FhirContext; + +public class FhirPathPatchServiceImplTest +{ + private static final FhirContext fhirContext = FhirContext.forR4(); + + private final FhirPathPatchService service = new FhirPathPatchServiceImpl(fhirContext); + + private static ParametersParameterComponent operation(String type, String path) + { + ParametersParameterComponent op = new ParametersParameterComponent().setName("operation"); + op.addPart().setName("type").setValue(new CodeType(type)); + op.addPart().setName("path").setValue(new StringType(path)); + return op; + } + + private static ParametersParameterComponent withPart(ParametersParameterComponent op, String name, Type value) + { + op.addPart().setName(name).setValue(value); + return op; + } + + private static Parameters patch(ParametersParameterComponent... operations) + { + Parameters p = new Parameters(); + for (ParametersParameterComponent op : operations) + p.addParameter(op); + return p; + } + + @Test + public void testReplacePrimitiveValue() + { + Patient patient = new Patient(); + patient.setActive(true); + + Patient result = service.apply(patient, + patch(withPart(operation("replace", "Patient.active"), "value", new BooleanType(false)))); + + assertFalse(result.getActive()); + // original must not be modified + assertTrue(patient.getActive()); + } + + @Test + public void testAddPrimitiveValue() + { + Patient patient = new Patient(); + + ParametersParameterComponent add = operation("add", "Patient"); + add.addPart().setName("name").setValue(new StringType("birthDate")); + add.addPart().setName("value").setValue(new DateType("1980-01-01")); + + Patient result = service.apply(patient, patch(add)); + + assertEquals("1980-01-01", result.getBirthDateElement().getValueAsString()); + } + + @Test + public void testAddComplexValueToList() + { + Patient patient = new Patient(); + + ParametersParameterComponent add = operation("add", "Patient"); + add.addPart().setName("name").setValue(new StringType("identifier")); + add.addPart().setName("value").setValue(new Identifier().setSystem("http://example.com/sid").setValue("42")); + + Patient result = service.apply(patient, patch(add)); + + assertEquals(1, result.getIdentifier().size()); + assertEquals("http://example.com/sid", result.getIdentifierFirstRep().getSystem()); + assertEquals("42", result.getIdentifierFirstRep().getValue()); + } + + @Test + public void testInsertIntoList() + { + Patient patient = new Patient(); + patient.addName(new HumanName().setFamily("First")); + patient.addName(new HumanName().setFamily("Third")); + + ParametersParameterComponent insert = operation("insert", "Patient.name"); + insert.addPart().setName("value").setValue(new HumanName().setFamily("Second")); + insert.addPart().setName("index").setValue(new IntegerType(1)); + + Patient result = service.apply(patient, patch(insert)); + + List names = result.getName(); + assertEquals(3, names.size()); + assertEquals("First", names.get(0).getFamily()); + assertEquals("Second", names.get(1).getFamily()); + assertEquals("Third", names.get(2).getFamily()); + } + + @Test + public void testDeleteSingleValued() + { + Patient patient = new Patient(); + patient.setBirthDateElement(new DateType("1980-01-01")); + + Patient result = service.apply(patient, patch(operation("delete", "Patient.birthDate"))); + + assertNull(result.getBirthDate()); + // original untouched + assertEquals("1980-01-01", patient.getBirthDateElement().getValueAsString()); + } + + @Test + public void testDeleteListElementByIndex() + { + Patient patient = new Patient(); + patient.addIdentifier(new Identifier().setValue("keep-0")); + patient.addIdentifier(new Identifier().setValue("remove-1")); + patient.addIdentifier(new Identifier().setValue("keep-2")); + + Patient result = service.apply(patient, patch(operation("delete", "Patient.identifier[1]"))); + + assertEquals(2, result.getIdentifier().size()); + assertEquals("keep-0", result.getIdentifier().get(0).getValue()); + assertEquals("keep-2", result.getIdentifier().get(1).getValue()); + } + + @Test + public void testMoveListElement() + { + Patient patient = new Patient(); + patient.addName(new HumanName().addGiven("a").addGiven("b").addGiven("c")); + + ParametersParameterComponent move = operation("move", "Patient.name[0].given"); + move.addPart().setName("source").setValue(new IntegerType(0)); + move.addPart().setName("destination").setValue(new IntegerType(2)); + + Patient result = service.apply(patient, patch(move)); + + List given = result.getNameFirstRep().getGiven(); + assertEquals(3, given.size()); + assertEquals("b", given.get(0).getValue()); + assertEquals("c", given.get(1).getValue()); + assertEquals("a", given.get(2).getValue()); + } + + @Test + public void testDeleteIsIdempotent() + { + Patient patient = new Patient(); + + Patient result = service.apply(patient, patch(operation("delete", "Patient.birthDate"))); + + assertNull(result.getBirthDate()); + } + + @Test + public void testReplaceListElementByIndex() + { + Patient patient = new Patient(); + patient.addIdentifier(new Identifier().setValue("old")); + + Patient result = service.apply(patient, patch( + withPart(operation("replace", "Patient.identifier[0]"), "value", new Identifier().setValue("new")))); + + assertEquals(1, result.getIdentifier().size()); + assertEquals("new", result.getIdentifierFirstRep().getValue()); + } + + @Test + public void testUnknownChildThrows() + { + Patient patient = new Patient(); + + ParametersParameterComponent add = operation("add", "Patient"); + add.addPart().setName("name").setValue(new StringType("doesNotExist")); + add.addPart().setName("value").setValue(new StringType("x")); + + assertThrows(FhirPatchException.class, () -> service.apply(patient, patch(add))); + } + + @Test + public void testUnknownOperationTypeThrows() + { + Patient patient = new Patient(); + assertThrows(FhirPatchException.class, + () -> service.apply(patient, patch(operation("upsert", "Patient.active")))); + } + + @Test + public void testNoOperationsThrows() + { + Patient patient = new Patient(); + assertThrows(FhirPatchException.class, () -> service.apply(patient, new Parameters())); + } +} diff --git a/dsf-fhir/dsf-fhir-webservice-client/src/main/java/dev/dsf/fhir/client/BasicFhirWebserviceCientWithRetryImpl.java b/dsf-fhir/dsf-fhir-webservice-client/src/main/java/dev/dsf/fhir/client/BasicFhirWebserviceCientWithRetryImpl.java index eb764ccd3..654c9f317 100644 --- a/dsf-fhir/dsf-fhir-webservice-client/src/main/java/dev/dsf/fhir/client/BasicFhirWebserviceCientWithRetryImpl.java +++ b/dsf-fhir/dsf-fhir-webservice-client/src/main/java/dev/dsf/fhir/client/BasicFhirWebserviceCientWithRetryImpl.java @@ -24,6 +24,7 @@ import org.hl7.fhir.r4.model.Bundle; import org.hl7.fhir.r4.model.CapabilityStatement; import org.hl7.fhir.r4.model.IdType; +import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Resource; import org.hl7.fhir.r4.model.StructureDefinition; @@ -55,6 +56,19 @@ public R update(R resource) return retry(() -> delegate.update(resource)); } + @Override + public R patch(Class resourceType, String id, Parameters patch) + { + return retry(() -> delegate.patch(resourceType, id, patch)); + } + + @Override + public R patchConditionaly(Class resourceType, Parameters patch, + Map> criteria) + { + return retry(() -> delegate.patchConditionaly(resourceType, patch, criteria)); + } + @Override public Bundle postBundle(Bundle bundle) { diff --git a/dsf-fhir/dsf-fhir-webservice-client/src/main/java/dev/dsf/fhir/client/FhirWebserviceClientJersey.java b/dsf-fhir/dsf-fhir-webservice-client/src/main/java/dev/dsf/fhir/client/FhirWebserviceClientJersey.java index ff6e90bfa..52a412d5e 100755 --- a/dsf-fhir/dsf-fhir-webservice-client/src/main/java/dev/dsf/fhir/client/FhirWebserviceClientJersey.java +++ b/dsf-fhir/dsf-fhir-webservice-client/src/main/java/dev/dsf/fhir/client/FhirWebserviceClientJersey.java @@ -279,6 +279,54 @@ PreferReturn updateConditionaly(PreferReturnType returnType, Resource resource, throw handleError(response); } + PreferReturn patch(PreferReturnType returnType, Class resourceType, String id, Parameters patch) + { + Objects.requireNonNull(returnType, "returnType"); + Objects.requireNonNull(resourceType, "resourceType"); + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(patch, "patch"); + + Builder builder = getResource().path(resourceType.getAnnotation(ResourceDef.class).name()).path(id).request() + .header(Constants.HEADER_PREFER, returnType.getHeaderValue()).accept(Constants.CT_FHIR_JSON_NEW); + + Response response = builder.method("PATCH", Entity.entity(patch, Constants.CT_FHIR_JSON_NEW)); + + logStatusAndHeaders(response); + + if (Status.OK.getStatusCode() == response.getStatus()) + return toPreferReturn(returnType, resourceType, response); + else + throw handleError(response); + } + + PreferReturn patchConditionaly(PreferReturnType returnType, Class resourceType, + Parameters patch, Map> criteria) + { + Objects.requireNonNull(returnType, "returnType"); + Objects.requireNonNull(resourceType, "resourceType"); + Objects.requireNonNull(patch, "patch"); + Objects.requireNonNull(criteria, "criteria"); + if (criteria.isEmpty()) + throw new IllegalArgumentException("criteria map empty"); + + WebTarget target = getResource().path(resourceType.getAnnotation(ResourceDef.class).name()); + + for (Entry> entry : criteria.entrySet()) + target = target.queryParam(entry.getKey(), entry.getValue().toArray()); + + Builder builder = target.request().accept(Constants.CT_FHIR_JSON_NEW).header(Constants.HEADER_PREFER, + returnType.getHeaderValue()); + + Response response = builder.method("PATCH", Entity.entity(patch, Constants.CT_FHIR_JSON_NEW)); + + logStatusAndHeaders(response); + + if (Status.OK.getStatusCode() == response.getStatus()) + return toPreferReturn(returnType, resourceType, response); + else + throw handleError(response); + } + PreferReturn updateBinary(PreferReturnType returnType, String id, InputStream in, MediaType mediaType, String securityContextReference) { @@ -345,6 +393,21 @@ public R update(R resource) return (R) update(PreferReturnType.REPRESENTATION, resource).getResource(); } + @Override + @SuppressWarnings("unchecked") + public R patch(Class resourceType, String id, Parameters patch) + { + return (R) patch(PreferReturnType.REPRESENTATION, resourceType, id, patch).getResource(); + } + + @Override + @SuppressWarnings("unchecked") + public R patchConditionaly(Class resourceType, Parameters patch, + Map> criteria) + { + return (R) patchConditionaly(PreferReturnType.REPRESENTATION, resourceType, patch, criteria).getResource(); + } + @Override @SuppressWarnings("unchecked") public R updateConditionaly(R resource, Map> criteria) diff --git a/dsf-fhir/dsf-fhir-webservice-client/src/main/java/dev/dsf/fhir/client/PreferReturnResource.java b/dsf-fhir/dsf-fhir-webservice-client/src/main/java/dev/dsf/fhir/client/PreferReturnResource.java index a02f3a764..13a520963 100644 --- a/dsf-fhir/dsf-fhir-webservice-client/src/main/java/dev/dsf/fhir/client/PreferReturnResource.java +++ b/dsf-fhir/dsf-fhir-webservice-client/src/main/java/dev/dsf/fhir/client/PreferReturnResource.java @@ -21,6 +21,7 @@ import org.hl7.fhir.r4.model.Binary; import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Resource; import jakarta.ws.rs.core.MediaType; @@ -41,5 +42,11 @@ public interface PreferReturnResource Binary updateBinary(String id, InputStream in, MediaType mediaType, String securityContextReference); + R patch(Class resourceType, String id, Parameters patch); + + R patchConditionaly(Class resourceType, Parameters patch, + Map> criteria); + + Bundle postBundle(Bundle bundle); } \ No newline at end of file