
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.Strings; 048import org.apache.commons.lang3.Validate; 049import org.hl7.fhir.instance.model.api.IBase; 050import org.hl7.fhir.instance.model.api.IBaseBundle; 051import org.hl7.fhir.instance.model.api.IBaseReference; 052import org.hl7.fhir.instance.model.api.IBaseResource; 053import org.hl7.fhir.instance.model.api.IIdType; 054import org.hl7.fhir.r4.model.IdType; 055import org.springframework.beans.factory.annotation.Autowired; 056 057import java.util.ArrayList; 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, true); 184 if (compartmentSps.isEmpty()) { 185 return provideNonCompartmentMemberTypeResponse(null); 186 } 187 } 188 189 //noinspection EnumSwitchStatementWhichMissesCases 190 switch (theReadDetails.getRestOperationType()) { 191 case DELETE: 192 case PATCH: 193 case READ: 194 case VREAD: 195 case SEARCH_TYPE: 196 if (theReadDetails.getSearchParams() != null) { 197 SearchParameterMap params = theReadDetails.getSearchParams(); 198 if ("Patient".equals(theReadDetails.getResourceType())) { 199 List<String> idParts = getResourceIdList(params, "_id", false); 200 if (idParts.size() == 1) { 201 return provideCompartmentMemberInstanceResponse(theRequestDetails, idParts.get(0)); 202 } else { 203 return RequestPartitionId.allPartitions(); 204 } 205 } else { 206 for (RuntimeSearchParam nextCompartmentSp : compartmentSps) { 207 List<String> idParts = getResourceIdList(params, nextCompartmentSp.getName(), true); 208 if (!idParts.isEmpty()) { 209 return provideCompartmentMemberInstanceResponse(theRequestDetails, idParts.get(0)); 210 } 211 } 212 } 213 } else if (theReadDetails.getReadResourceId() != null) { 214 if ("Patient".equals(theReadDetails.getResourceType())) { 215 return provideCompartmentMemberInstanceResponse( 216 theRequestDetails, 217 theReadDetails.getReadResourceId().getIdPart()); 218 } 219 } 220 break; 221 case EXTENDED_OPERATION_SERVER: 222 String extendedOp = theReadDetails.getExtendedOperationName(); 223 if (ProviderConstants.OPERATION_EXPORT.equals(extendedOp) 224 || ProviderConstants.OPERATION_EXPORT_POLL_STATUS.equals(extendedOp)) { 225 return provideNonPatientSpecificQueryResponse(); 226 } 227 break; 228 default: 229 // nothing 230 } 231 232 if (isBlank(theReadDetails.getResourceType())) { 233 return provideNonCompartmentMemberTypeResponse(null); 234 } 235 236 // If we couldn't identify a patient ID by the URL, let's try using the 237 // conditional target if we have one 238 if (theReadDetails.getConditionalTargetOrNull() != null) { 239 return identifyForCreate(theReadDetails.getConditionalTargetOrNull(), theRequestDetails); 240 } 241 242 return provideNonPatientSpecificQueryResponse(); 243 } 244 245 /** 246 * If we're about to process a FHIR transaction, we want to note the mappings between placeholder IDs 247 * and their resources and stuff them into a userdata map where we can access them later. We do this 248 * so that when we see a resource in the patient compartment (e.g. an Encounter) and it has a subject 249 * reference that's just a placeholder ID, we can look up the target of that and figure out which 250 * compartment that Encounter actually belongs to. 251 */ 252 @Hook(Pointcut.STORAGE_TRANSACTION_PROCESSING) 253 public void transaction(RequestDetails theRequestDetails, IBaseBundle theBundle) { 254 FhirTerser terser = myFhirContext.newTerser(); 255 256 /* 257 * If we have a Patient in the transaction bundle which is being POST-ed as a normal 258 * resource "create" (i.e., it will get a server-assigned ID), we'll proactively assign it an ID here. 259 * 260 * This is mostly a hack to get Synthea data working, but real clients could also be 261 * following the same pattern. 262 */ 263 List<IBase> rawEntries = new ArrayList<>(terser.getValues(theBundle, "entry", IBase.class)); 264 List<BundleEntryParts> parsedEntries = BundleUtil.toListOfEntries(myFhirContext, theBundle); 265 Validate.isTrue(rawEntries.size() == parsedEntries.size(), "Parsed and raw entries don't match"); 266 267 Map<String, String> idSubstitutions = new HashMap<>(); 268 for (int i = 0; i < rawEntries.size(); i++) { 269 BundleEntryParts nextEntry = parsedEntries.get(i); 270 if (nextEntry.getResource() != null 271 && myFhirContext.getResourceType(nextEntry.getResource()).equals("Patient")) { 272 if (nextEntry.getMethod() == RequestTypeEnum.POST && isBlank(nextEntry.getConditionalUrl())) { 273 if (nextEntry.getFullUrl() != null && nextEntry.getFullUrl().startsWith("urn:uuid:")) { 274 String newId = UUID.randomUUID().toString(); 275 nextEntry.getResource().setId(newId); 276 idSubstitutions.put(nextEntry.getFullUrl(), "Patient/" + newId); 277 278 IBase entry = rawEntries.get(i); 279 IBase request = terser.getValues(entry, "request").get(0); 280 terser.setElement(request, "ifNoneExist", null); 281 terser.setElement(request, "method", "PUT"); 282 terser.setElement(request, "url", "Patient/" + newId); 283 } 284 } 285 } 286 } 287 288 if (!idSubstitutions.isEmpty()) { 289 for (BundleEntryParts entry : parsedEntries) { 290 IBaseResource resource = entry.getResource(); 291 if (resource != null) { 292 List<ResourceReferenceInfo> references = terser.getAllResourceReferences(resource); 293 for (ResourceReferenceInfo reference : references) { 294 String referenceString = reference 295 .getResourceReference() 296 .getReferenceElement() 297 .getValue(); 298 String substitution = idSubstitutions.get(referenceString); 299 if (substitution != null) { 300 reference.getResourceReference().setReference(substitution); 301 } 302 } 303 } 304 } 305 } 306 307 List<BundleEntryParts> entries = BundleUtil.toListOfEntries(myFhirContext, theBundle); 308 Map<String, IBaseResource> placeholderToResource = new HashMap<>(); 309 for (BundleEntryParts nextEntry : entries) { 310 String fullUrl = nextEntry.getFullUrl(); 311 if (fullUrl != null && fullUrl.startsWith("urn:uuid:")) { 312 if (nextEntry.getResource() != null) { 313 placeholderToResource.put(fullUrl, nextEntry.getResource()); 314 } 315 } 316 } 317 318 if (theRequestDetails != null) { 319 theRequestDetails.getUserData().put(PLACEHOLDER_TO_REFERENCE_KEY, placeholderToResource); 320 } 321 } 322 323 @SuppressWarnings("SameParameterValue") 324 private List<String> getResourceIdList( 325 SearchParameterMap theParams, String theParamName, boolean theExpectOnlyOneBool) { 326 List<List<IQueryParameterType>> idParamAndList = theParams.get(theParamName); 327 if (idParamAndList == null) { 328 return Collections.emptyList(); 329 } 330 331 List<String> idParts = new ArrayList<>(); 332 for (List<IQueryParameterType> iQueryParameterTypes : idParamAndList) { 333 for (IQueryParameterType idParam : iQueryParameterTypes) { 334 if (isNotBlank(idParam.getQueryParameterQualifier())) { 335 throw new MethodNotAllowedException(Msg.code(1322) + "The parameter " + theParamName 336 + idParam.getQueryParameterQualifier() + " is not supported in patient compartment mode"); 337 } 338 if (idParam instanceof ReferenceParam) { 339 String chain = ((ReferenceParam) idParam).getChain(); 340 if (chain != null) { 341 throw new MethodNotAllowedException(Msg.code(1323) + "The parameter " + theParamName + "." 342 + chain + " is not supported in patient compartment mode"); 343 } 344 } 345 346 String valueAsQueryToken = idParam.getValueAsQueryToken(); 347 if (Strings.CS.startsWith(valueAsQueryToken, "Patient/")) { 348 IdType id = new IdType(valueAsQueryToken); 349 if (id.getResourceType().equals("Patient")) { 350 idParts.add(id.getIdPart()); 351 } 352 } else if (valueAsQueryToken.indexOf('/') == -1) { 353 IdType id = new IdType(valueAsQueryToken); 354 if (id.isIdPartValid()) { 355 idParts.add(valueAsQueryToken); 356 } 357 } 358 } 359 } 360 361 if (theExpectOnlyOneBool && idParts.size() > 1) { 362 throw new MethodNotAllowedException(Msg.code(1324) + "Multiple values for parameter " + theParamName 363 + " is not supported in patient compartment mode"); 364 } 365 366 return idParts; 367 } 368 369 /** 370 * Return a partition or throw an error for FHIR operations that can not be used with this interceptor 371 */ 372 protected RequestPartitionId provideNonPatientSpecificQueryResponse() { 373 return RequestPartitionId.allPartitions(); 374 } 375 376 /** 377 * Generate the partition for a given patient resource ID. This method may be overridden in subclasses, but it 378 * may be easier to override {@link #providePartitionIdForPatientId(RequestDetails, String)} instead. 379 */ 380 @Nonnull 381 protected RequestPartitionId provideCompartmentMemberInstanceResponse( 382 RequestDetails theRequestDetails, String theResourceIdPart) { 383 int partitionId = providePartitionIdForPatientId(theRequestDetails, theResourceIdPart); 384 return RequestPartitionId.fromPartitionIdAndName(partitionId, theResourceIdPart); 385 } 386 387 /** 388 * Translates an ID (e.g. "ABC") into a compartment ID number. 389 * <p> 390 * The default implementation of this method returns: 391 * <code>Math.abs(theResourceIdPart.hashCode()) % 15000</code>. 392 * <p> 393 * This logic can be replaced with other logic of your choosing. 394 */ 395 @SuppressWarnings("unused") 396 protected int providePartitionIdForPatientId(RequestDetails theRequestDetails, String theResourceIdPart) { 397 return Math.abs(theResourceIdPart.hashCode() % 15000); 398 } 399 400 /** 401 * Return a compartment ID (or throw an exception) when an attempt is made to search for a resource that is 402 * in the patient compartment, but without any search parameter identifying which compartment to search. 403 * <p> 404 * E.g. this method will be called for the search <code>Observation?code=foo</code> since the patient 405 * is not identified in the URL. 406 */ 407 @Nonnull 408 protected RequestPartitionId throwNonCompartmentMemberInstanceFailureResponse(IBaseResource theResource) { 409 throw new MethodNotAllowedException(Msg.code(1326) + "Resource of type " 410 + myFhirContext.getResourceType(theResource) + " has no values placing it in the Patient compartment"); 411 } 412 413 /** 414 * Return a compartment ID (or throw an exception) when storing/reading resource types that 415 * are not in the patient compartment (e.g. ValueSet). 416 */ 417 @SuppressWarnings("unused") 418 @Nonnull 419 protected RequestPartitionId provideNonCompartmentMemberTypeResponse(IBaseResource theResource) { 420 return myPartitionSettings.getDefaultRequestPartitionId(); 421 } 422}