
001/*- 002 * #%L 003 * HAPI FHIR Storage api 004 * %% 005 * Copyright (C) 2014 - 2025 Smile CDR, Inc. 006 * %% 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * You may obtain a copy of the License at 010 * 011 * http://www.apache.org/licenses/LICENSE-2.0 012 * 013 * Unless required by applicable law or agreed to in writing, software 014 * distributed under the License is distributed on an "AS IS" BASIS, 015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 016 * See the License for the specific language governing permissions and 017 * limitations under the License. 018 * #L% 019 */ 020package ca.uhn.fhir.jpa.interceptor; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.context.RuntimeResourceDefinition; 024import ca.uhn.fhir.context.RuntimeSearchParam; 025import ca.uhn.fhir.i18n.Msg; 026import ca.uhn.fhir.interceptor.api.Hook; 027import ca.uhn.fhir.interceptor.api.Interceptor; 028import ca.uhn.fhir.interceptor.api.Pointcut; 029import ca.uhn.fhir.interceptor.auth.CompartmentSearchParameterModifications; 030import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails; 031import ca.uhn.fhir.interceptor.model.RequestPartitionId; 032import ca.uhn.fhir.jpa.model.config.PartitionSettings; 033import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; 034import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor; 035import ca.uhn.fhir.jpa.util.ResourceCompartmentUtil; 036import ca.uhn.fhir.model.api.IQueryParameterType; 037import ca.uhn.fhir.rest.api.RequestTypeEnum; 038import ca.uhn.fhir.rest.api.server.RequestDetails; 039import ca.uhn.fhir.rest.param.ReferenceParam; 040import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException; 041import ca.uhn.fhir.rest.server.provider.ProviderConstants; 042import ca.uhn.fhir.util.BundleUtil; 043import ca.uhn.fhir.util.FhirTerser; 044import ca.uhn.fhir.util.ResourceReferenceInfo; 045import ca.uhn.fhir.util.bundle.BundleEntryParts; 046import jakarta.annotation.Nonnull; 047import org.apache.commons.lang3.Validate; 048import org.hl7.fhir.instance.model.api.IBase; 049import org.hl7.fhir.instance.model.api.IBaseBundle; 050import org.hl7.fhir.instance.model.api.IBaseReference; 051import org.hl7.fhir.instance.model.api.IBaseResource; 052import org.hl7.fhir.instance.model.api.IIdType; 053import org.hl7.fhir.r4.model.IdType; 054import org.springframework.beans.factory.annotation.Autowired; 055 056import java.util.ArrayList; 057import java.util.Collection; 058import java.util.Collections; 059import java.util.HashMap; 060import java.util.List; 061import java.util.Map; 062import java.util.Objects; 063import java.util.Optional; 064import java.util.UUID; 065 066import static ca.uhn.fhir.interceptor.model.RequestPartitionId.getPartitionIfAssigned; 067import static org.apache.commons.lang3.StringUtils.isBlank; 068import static org.apache.commons.lang3.StringUtils.isEmpty; 069import static org.apache.commons.lang3.StringUtils.isNotBlank; 070 071/** 072 * This interceptor allows JPA servers to be partitioned by Patient ID. It selects the compartment for read/create operations 073 * based on the patient ID associated with the resource (and uses a default partition ID for any resources 074 * not in the patient compartment). 075 * This works better with IdStrategyEnum.UUID and CrossPartitionReferenceMode.ALLOWED_UNQUALIFIED. 076 */ 077@Interceptor 078public class PatientIdPartitionInterceptor { 079 080 public static final String PLACEHOLDER_TO_REFERENCE_KEY = 081 PatientIdPartitionInterceptor.class.getName() + "_placeholderToResource"; 082 083 @Autowired 084 private FhirContext myFhirContext; 085 086 @Autowired 087 private ISearchParamExtractor mySearchParamExtractor; 088 089 @Autowired 090 private PartitionSettings myPartitionSettings; 091 092 /** 093 * Constructor 094 */ 095 public PatientIdPartitionInterceptor( 096 FhirContext theFhirContext, 097 ISearchParamExtractor theSearchParamExtractor, 098 PartitionSettings thePartitionSettings) { 099 myFhirContext = theFhirContext; 100 mySearchParamExtractor = theSearchParamExtractor; 101 myPartitionSettings = thePartitionSettings; 102 } 103 104 @Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE) 105 public RequestPartitionId identifyForCreate(IBaseResource theResource, RequestDetails theRequestDetails) { 106 RuntimeResourceDefinition resourceDef = myFhirContext.getResourceDefinition(theResource); 107 List<RuntimeSearchParam> compartmentSps = 108 ResourceCompartmentUtil.getPatientCompartmentSearchParams(resourceDef); 109 110 if (compartmentSps.isEmpty() || resourceDef.getName().equals("Group")) { 111 return provideNonCompartmentMemberTypeResponse(theResource); 112 } 113 114 if (resourceDef.getName().equals("Patient")) { 115 IIdType idElement = theResource.getIdElement(); 116 if (idElement.getIdPart() == null || idElement.isUuid()) { 117 throw new MethodNotAllowedException( 118 Msg.code(1321) 119 + "Patient resource IDs must be client-assigned in patient compartment mode, or server id strategy must be UUID"); 120 } 121 return provideCompartmentMemberInstanceResponse(theRequestDetails, idElement.getIdPart()); 122 } else { 123 Optional<String> oCompartmentIdentity = ResourceCompartmentUtil.getResourceCompartment( 124 "Patient", theResource, compartmentSps, mySearchParamExtractor); 125 126 if (oCompartmentIdentity.isPresent()) { 127 return provideCompartmentMemberInstanceResponse(theRequestDetails, oCompartmentIdentity.get()); 128 } else { 129 return getPartitionViaPartiallyProcessedReference(theRequestDetails, theResource) 130 // or give up and fail 131 .orElseGet(() -> throwNonCompartmentMemberInstanceFailureResponse(theResource)); 132 } 133 } 134 } 135 136 /** 137 * HACK: enable synthea bundles to sneak through with a server-assigned UUID. 138 * If we don't have a simple id for a compartment owner, maybe we're in a bundle during processing 139 * and a reference points to the Patient which has already been processed and assigned a partition. 140 */ 141 @SuppressWarnings("unchecked") 142 @Nonnull 143 private Optional<RequestPartitionId> getPartitionViaPartiallyProcessedReference( 144 RequestDetails theRequestDetails, IBaseResource theResource) { 145 Map<String, IBaseResource> placeholderToReference = null; 146 if (theRequestDetails != null) { 147 placeholderToReference = 148 (Map<String, IBaseResource>) theRequestDetails.getUserData().get(PLACEHOLDER_TO_REFERENCE_KEY); 149 } 150 if (placeholderToReference == null) { 151 placeholderToReference = Map.of(); 152 } 153 154 List<IBaseReference> references = myFhirContext 155 .newTerser() 156 .getCompartmentReferencesForResource( 157 "Patient", theResource, new CompartmentSearchParameterModifications()) 158 .toList(); 159 for (IBaseReference reference : references) { 160 String referenceString = reference.getReferenceElement().getValue(); 161 IBaseResource target = placeholderToReference.get(referenceString); 162 if (target != null && Objects.equals(myFhirContext.getResourceType(target), "Patient")) { 163 if ("Patient".equals(target.getIdElement().getResourceType())) { 164 if (!target.getIdElement().isUuid() && target.getIdElement().hasIdPart()) { 165 return Optional.of(provideCompartmentMemberInstanceResponse( 166 theRequestDetails, target.getIdElement().getIdPart())); 167 } 168 } 169 return getPartitionIfAssigned(target); 170 } 171 } 172 173 return Optional.empty(); 174 } 175 176 @Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_READ) 177 public RequestPartitionId identifyForRead( 178 @Nonnull ReadPartitionIdRequestDetails theReadDetails, RequestDetails theRequestDetails) { 179 List<RuntimeSearchParam> compartmentSps = Collections.emptyList(); 180 if (!isEmpty(theReadDetails.getResourceType())) { 181 RuntimeResourceDefinition resourceDef = 182 myFhirContext.getResourceDefinition(theReadDetails.getResourceType()); 183 compartmentSps = ResourceCompartmentUtil.getPatientCompartmentSearchParams(resourceDef); 184 if (compartmentSps.isEmpty()) { 185 return provideNonCompartmentMemberTypeResponse(null); 186 } 187 } 188 189 //noinspection EnumSwitchStatementWhichMissesCases 190 switch (theReadDetails.getRestOperationType()) { 191 case READ: 192 case VREAD: 193 if ("Patient".equals(theReadDetails.getResourceType())) { 194 return provideCompartmentMemberInstanceResponse( 195 theRequestDetails, 196 theReadDetails.getReadResourceId().getIdPart()); 197 } 198 break; 199 case SEARCH_TYPE: 200 SearchParameterMap params = theReadDetails.getSearchParams(); 201 assert params != null; 202 if ("Patient".equals(theReadDetails.getResourceType())) { 203 List<String> idParts = getResourceIdList(params, "_id", "Patient", false); 204 if (idParts.size() == 1) { 205 return provideCompartmentMemberInstanceResponse(theRequestDetails, idParts.get(0)); 206 } else { 207 return RequestPartitionId.allPartitions(); 208 } 209 } else { 210 for (RuntimeSearchParam nextCompartmentSp : compartmentSps) { 211 List<String> idParts = getResourceIdList(params, nextCompartmentSp.getName(), "Patient", true); 212 if (!idParts.isEmpty()) { 213 return provideCompartmentMemberInstanceResponse(theRequestDetails, idParts.get(0)); 214 } 215 } 216 } 217 218 break; 219 case EXTENDED_OPERATION_SERVER: 220 String extendedOp = theReadDetails.getExtendedOperationName(); 221 if (ProviderConstants.OPERATION_EXPORT.equals(extendedOp) 222 || ProviderConstants.OPERATION_EXPORT_POLL_STATUS.equals(extendedOp)) { 223 return provideNonPatientSpecificQueryResponse(); 224 } 225 break; 226 default: 227 // nothing 228 } 229 230 if (isBlank(theReadDetails.getResourceType())) { 231 return provideNonCompartmentMemberTypeResponse(null); 232 } 233 234 // If we couldn't identify a patient ID by the URL, let's try using the 235 // conditional target if we have one 236 if (theReadDetails.getConditionalTargetOrNull() != null) { 237 return identifyForCreate(theReadDetails.getConditionalTargetOrNull(), theRequestDetails); 238 } 239 240 return provideNonPatientSpecificQueryResponse(); 241 } 242 243 /** 244 * If we're about to process a FHIR transaction, we want to note the mappings between placeholder IDs 245 * and their resources and stuff them into a userdata map where we can access them later. We do this 246 * so that when we see a resource in the patient compartment (e.g. an Encounter) and it has a subject 247 * reference that's just a placeholder ID, we can look up the target of that and figure out which 248 * compartment that Encounter actually belongs to. 249 */ 250 @Hook(Pointcut.STORAGE_TRANSACTION_PROCESSING) 251 public void transaction(RequestDetails theRequestDetails, IBaseBundle theBundle) { 252 FhirTerser terser = myFhirContext.newTerser(); 253 254 /* 255 * If we have a Patient in the transaction bundle which is being POST-ed as a normal 256 * resource "create" (i.e., it will get a server-assigned ID), we'll proactively assign it an ID here. 257 * 258 * This is mostly a hack to get Synthea data working, but real clients could also be 259 * following the same pattern. 260 */ 261 List<IBase> rawEntries = new ArrayList<>(terser.getValues(theBundle, "entry", IBase.class)); 262 List<BundleEntryParts> parsedEntries = BundleUtil.toListOfEntries(myFhirContext, theBundle); 263 Validate.isTrue(rawEntries.size() == parsedEntries.size(), "Parsed and raw entries don't match"); 264 265 Map<String, String> idSubstitutions = new HashMap<>(); 266 for (int i = 0; i < rawEntries.size(); i++) { 267 BundleEntryParts nextEntry = parsedEntries.get(i); 268 if (nextEntry.getResource() != null 269 && myFhirContext.getResourceType(nextEntry.getResource()).equals("Patient")) { 270 if (nextEntry.getMethod() == RequestTypeEnum.POST && isBlank(nextEntry.getConditionalUrl())) { 271 if (nextEntry.getFullUrl() != null && nextEntry.getFullUrl().startsWith("urn:uuid:")) { 272 String newId = UUID.randomUUID().toString(); 273 nextEntry.getResource().setId(newId); 274 idSubstitutions.put(nextEntry.getFullUrl(), "Patient/" + newId); 275 276 IBase entry = rawEntries.get(i); 277 IBase request = terser.getValues(entry, "request").get(0); 278 terser.setElement(request, "ifNoneExist", null); 279 terser.setElement(request, "method", "PUT"); 280 terser.setElement(request, "url", "Patient/" + newId); 281 } 282 } 283 } 284 } 285 286 if (!idSubstitutions.isEmpty()) { 287 for (BundleEntryParts entry : parsedEntries) { 288 IBaseResource resource = entry.getResource(); 289 if (resource != null) { 290 List<ResourceReferenceInfo> references = terser.getAllResourceReferences(resource); 291 for (ResourceReferenceInfo reference : references) { 292 String referenceString = reference 293 .getResourceReference() 294 .getReferenceElement() 295 .getValue(); 296 String substitution = idSubstitutions.get(referenceString); 297 if (substitution != null) { 298 reference.getResourceReference().setReference(substitution); 299 } 300 } 301 } 302 } 303 } 304 305 List<BundleEntryParts> entries = BundleUtil.toListOfEntries(myFhirContext, theBundle); 306 Map<String, IBaseResource> placeholderToResource = new HashMap<>(); 307 for (BundleEntryParts nextEntry : entries) { 308 String fullUrl = nextEntry.getFullUrl(); 309 if (fullUrl != null && fullUrl.startsWith("urn:uuid:")) { 310 if (nextEntry.getResource() != null) { 311 placeholderToResource.put(fullUrl, nextEntry.getResource()); 312 } 313 } 314 } 315 316 if (theRequestDetails != null) { 317 theRequestDetails.getUserData().put(PLACEHOLDER_TO_REFERENCE_KEY, placeholderToResource); 318 } 319 } 320 321 @SuppressWarnings("SameParameterValue") 322 private List<String> getResourceIdList( 323 SearchParameterMap theParams, String theParamName, String theResourceType, boolean theExpectOnlyOneBool) { 324 List<List<IQueryParameterType>> idParamAndList = theParams.get(theParamName); 325 if (idParamAndList == null) { 326 return Collections.emptyList(); 327 } 328 329 List<String> idParts = new ArrayList<>(); 330 idParamAndList.stream().flatMap(Collection::stream).forEach(idParam -> { 331 if (isNotBlank(idParam.getQueryParameterQualifier())) { 332 throw new MethodNotAllowedException(Msg.code(1322) + "The parameter " + theParamName 333 + idParam.getQueryParameterQualifier() + " is not supported in patient compartment mode"); 334 } 335 if (idParam instanceof ReferenceParam) { 336 String chain = ((ReferenceParam) idParam).getChain(); 337 if (chain != null) { 338 throw new MethodNotAllowedException(Msg.code(1323) + "The parameter " + theParamName + "." + chain 339 + " is not supported in patient compartment mode"); 340 } 341 } 342 IdType id = new IdType(idParam.getValueAsQueryToken()); 343 if (!id.hasResourceType() || id.getResourceType().equals(theResourceType)) { 344 idParts.add(id.getIdPart()); 345 } 346 }); 347 348 if (theExpectOnlyOneBool && idParts.size() > 1) { 349 throw new MethodNotAllowedException(Msg.code(1324) + "Multiple values for parameter " + theParamName 350 + " is not supported in patient compartment mode"); 351 } 352 353 return idParts; 354 } 355 356 /** 357 * Return a partition or throw an error for FHIR operations that can not be used with this interceptor 358 */ 359 protected RequestPartitionId provideNonPatientSpecificQueryResponse() { 360 return RequestPartitionId.allPartitions(); 361 } 362 363 /** 364 * Generate the partition for a given patient resource ID. This method may be overridden in subclasses, but it 365 * may be easier to override {@link #providePartitionIdForPatientId(RequestDetails, String)} instead. 366 */ 367 @Nonnull 368 protected RequestPartitionId provideCompartmentMemberInstanceResponse( 369 RequestDetails theRequestDetails, String theResourceIdPart) { 370 int partitionId = providePartitionIdForPatientId(theRequestDetails, theResourceIdPart); 371 return RequestPartitionId.fromPartitionIdAndName(partitionId, theResourceIdPart); 372 } 373 374 /** 375 * Translates an ID (e.g. "ABC") into a compartment ID number. 376 * <p> 377 * The default implementation of this method returns: 378 * <code>Math.abs(theResourceIdPart.hashCode()) % 15000</code>. 379 * <p> 380 * This logic can be replaced with other logic of your choosing. 381 */ 382 @SuppressWarnings("unused") 383 protected int providePartitionIdForPatientId(RequestDetails theRequestDetails, String theResourceIdPart) { 384 return Math.abs(theResourceIdPart.hashCode() % 15000); 385 } 386 387 /** 388 * Return a compartment ID (or throw an exception) when an attempt is made to search for a resource that is 389 * in the patient compartment, but without any search parameter identifying which compartment to search. 390 * <p> 391 * E.g. this method will be called for the search <code>Observation?code=foo</code> since the patient 392 * is not identified in the URL. 393 */ 394 @Nonnull 395 protected RequestPartitionId throwNonCompartmentMemberInstanceFailureResponse(IBaseResource theResource) { 396 throw new MethodNotAllowedException(Msg.code(1326) + "Resource of type " 397 + myFhirContext.getResourceType(theResource) + " has no values placing it in the Patient compartment"); 398 } 399 400 /** 401 * Return a compartment ID (or throw an exception) when storing/reading resource types that 402 * are not in the patient compartment (e.g. ValueSet). 403 */ 404 @SuppressWarnings("unused") 405 @Nonnull 406 protected RequestPartitionId provideNonCompartmentMemberTypeResponse(IBaseResource theResource) { 407 return myPartitionSettings.getDefaultRequestPartitionId(); 408 } 409}