
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.model.ReadPartitionIdRequestDetails; 030import ca.uhn.fhir.interceptor.model.RequestPartitionId; 031import ca.uhn.fhir.jpa.model.config.PartitionSettings; 032import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; 033import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor; 034import ca.uhn.fhir.jpa.util.ResourceCompartmentUtil; 035import ca.uhn.fhir.model.api.IQueryParameterType; 036import ca.uhn.fhir.rest.api.server.RequestDetails; 037import ca.uhn.fhir.rest.param.ReferenceParam; 038import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException; 039import ca.uhn.fhir.rest.server.provider.ProviderConstants; 040import jakarta.annotation.Nonnull; 041import org.hl7.fhir.instance.model.api.IBaseReference; 042import org.hl7.fhir.instance.model.api.IBaseResource; 043import org.hl7.fhir.instance.model.api.IIdType; 044import org.hl7.fhir.r4.model.IdType; 045import org.springframework.beans.factory.annotation.Autowired; 046 047import java.util.ArrayList; 048import java.util.Collection; 049import java.util.Collections; 050import java.util.List; 051import java.util.Objects; 052import java.util.Optional; 053 054import static ca.uhn.fhir.interceptor.model.RequestPartitionId.getPartitionIfAssigned; 055import static org.apache.commons.lang3.StringUtils.isBlank; 056import static org.apache.commons.lang3.StringUtils.isEmpty; 057import static org.apache.commons.lang3.StringUtils.isNotBlank; 058 059/** 060 * This interceptor allows JPA servers to be partitioned by Patient ID. It selects the compartment for read/create operations 061 * based on the patient ID associated with the resource (and uses a default partition ID for any resources 062 * not in the patient compartment). 063 * This works better with IdStrategyEnum.UUID and CrossPartitionReferenceMode.ALLOWED_UNQUALIFIED. 064 */ 065@Interceptor 066public class PatientIdPartitionInterceptor { 067 068 @Autowired 069 private FhirContext myFhirContext; 070 071 @Autowired 072 private ISearchParamExtractor mySearchParamExtractor; 073 074 @Autowired 075 private PartitionSettings myPartitionSettings; 076 077 /** 078 * Constructor 079 */ 080 public PatientIdPartitionInterceptor( 081 FhirContext theFhirContext, 082 ISearchParamExtractor theSearchParamExtractor, 083 PartitionSettings thePartitionSettings) { 084 myFhirContext = theFhirContext; 085 mySearchParamExtractor = theSearchParamExtractor; 086 myPartitionSettings = thePartitionSettings; 087 } 088 089 @Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE) 090 public RequestPartitionId identifyForCreate(IBaseResource theResource, RequestDetails theRequestDetails) { 091 RuntimeResourceDefinition resourceDef = myFhirContext.getResourceDefinition(theResource); 092 List<RuntimeSearchParam> compartmentSps = 093 ResourceCompartmentUtil.getPatientCompartmentSearchParams(resourceDef); 094 095 if (compartmentSps.isEmpty()) { 096 return provideNonCompartmentMemberTypeResponse(theResource); 097 } 098 099 if (resourceDef.getName().equals("Patient")) { 100 IIdType idElement = theResource.getIdElement(); 101 if (idElement.getIdPart() == null || idElement.isUuid()) { 102 throw new MethodNotAllowedException( 103 Msg.code(1321) 104 + "Patient resource IDs must be client-assigned in patient compartment mode, or server id strategy must be UUID"); 105 } 106 return provideCompartmentMemberInstanceResponse(theRequestDetails, idElement.getIdPart()); 107 } else { 108 Optional<String> oCompartmentIdentity = ResourceCompartmentUtil.getResourceCompartment( 109 "Patient", theResource, compartmentSps, mySearchParamExtractor); 110 111 if (oCompartmentIdentity.isPresent()) { 112 return provideCompartmentMemberInstanceResponse(theRequestDetails, oCompartmentIdentity.get()); 113 } else { 114 return getPartitionViaPartiallyProcessedReference(theResource) 115 // or give up and fail 116 .orElseGet(() -> throwNonCompartmentMemberInstanceFailureResponse(theResource)); 117 } 118 } 119 } 120 121 /** 122 * HACK: enable synthea bundles to sneak through with a server-assigned UUID. 123 * If we don't have a simple id for a compartment owner, maybe we're in a bundle during processing 124 * and a reference points to the Patient which has already been processed and assigned a partition. 125 */ 126 @Nonnull 127 private Optional<RequestPartitionId> getPartitionViaPartiallyProcessedReference(IBaseResource theResource) { 128 return myFhirContext 129 .newTerser() 130 .getCompartmentReferencesForResource("Patient", theResource, null) 131 .map(IBaseReference::getResource) 132 .filter(Objects::nonNull) 133 .flatMap(nextResource -> getPartitionIfAssigned(nextResource).stream()) 134 .findFirst(); 135 } 136 137 @Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_READ) 138 public RequestPartitionId identifyForRead( 139 @Nonnull ReadPartitionIdRequestDetails theReadDetails, RequestDetails theRequestDetails) { 140 List<RuntimeSearchParam> compartmentSps = Collections.emptyList(); 141 if (!isEmpty(theReadDetails.getResourceType())) { 142 RuntimeResourceDefinition resourceDef = 143 myFhirContext.getResourceDefinition(theReadDetails.getResourceType()); 144 compartmentSps = ResourceCompartmentUtil.getPatientCompartmentSearchParams(resourceDef); 145 if (compartmentSps.isEmpty()) { 146 return provideNonCompartmentMemberTypeResponse(null); 147 } 148 } 149 150 //noinspection EnumSwitchStatementWhichMissesCases 151 switch (theReadDetails.getRestOperationType()) { 152 case READ: 153 case VREAD: 154 if ("Patient".equals(theReadDetails.getResourceType())) { 155 return provideCompartmentMemberInstanceResponse( 156 theRequestDetails, 157 theReadDetails.getReadResourceId().getIdPart()); 158 } 159 break; 160 case SEARCH_TYPE: 161 SearchParameterMap params = theReadDetails.getSearchParams(); 162 assert params != null; 163 if ("Patient".equals(theReadDetails.getResourceType())) { 164 List<String> idParts = getResourceIdList(params, "_id", "Patient", false); 165 if (idParts.size() == 1) { 166 return provideCompartmentMemberInstanceResponse(theRequestDetails, idParts.get(0)); 167 } else { 168 return RequestPartitionId.allPartitions(); 169 } 170 } else { 171 for (RuntimeSearchParam nextCompartmentSp : compartmentSps) { 172 List<String> idParts = getResourceIdList(params, nextCompartmentSp.getName(), "Patient", true); 173 if (!idParts.isEmpty()) { 174 return provideCompartmentMemberInstanceResponse(theRequestDetails, idParts.get(0)); 175 } 176 } 177 } 178 179 break; 180 case EXTENDED_OPERATION_SERVER: 181 String extendedOp = theReadDetails.getExtendedOperationName(); 182 if (ProviderConstants.OPERATION_EXPORT.equals(extendedOp) 183 || ProviderConstants.OPERATION_EXPORT_POLL_STATUS.equals(extendedOp)) { 184 return provideNonPatientSpecificQueryResponse(); 185 } 186 break; 187 default: 188 // nothing 189 } 190 191 if (isBlank(theReadDetails.getResourceType())) { 192 return provideNonCompartmentMemberTypeResponse(null); 193 } 194 195 // If we couldn't identify a patient ID by the URL, let's try using the 196 // conditional target if we have one 197 if (theReadDetails.getConditionalTargetOrNull() != null) { 198 return identifyForCreate(theReadDetails.getConditionalTargetOrNull(), theRequestDetails); 199 } 200 201 return provideNonPatientSpecificQueryResponse(); 202 } 203 204 @SuppressWarnings("SameParameterValue") 205 private List<String> getResourceIdList( 206 SearchParameterMap theParams, String theParamName, String theResourceType, boolean theExpectOnlyOneBool) { 207 List<List<IQueryParameterType>> idParamAndList = theParams.get(theParamName); 208 if (idParamAndList == null) { 209 return Collections.emptyList(); 210 } 211 212 List<String> idParts = new ArrayList<>(); 213 idParamAndList.stream().flatMap(Collection::stream).forEach(idParam -> { 214 if (isNotBlank(idParam.getQueryParameterQualifier())) { 215 throw new MethodNotAllowedException(Msg.code(1322) + "The parameter " + theParamName 216 + idParam.getQueryParameterQualifier() + " is not supported in patient compartment mode"); 217 } 218 if (idParam instanceof ReferenceParam) { 219 String chain = ((ReferenceParam) idParam).getChain(); 220 if (chain != null) { 221 throw new MethodNotAllowedException(Msg.code(1323) + "The parameter " + theParamName + "." + chain 222 + " is not supported in patient compartment mode"); 223 } 224 } 225 IdType id = new IdType(idParam.getValueAsQueryToken(myFhirContext)); 226 if (!id.hasResourceType() || id.getResourceType().equals(theResourceType)) { 227 idParts.add(id.getIdPart()); 228 } 229 }); 230 231 if (theExpectOnlyOneBool && idParts.size() > 1) { 232 throw new MethodNotAllowedException(Msg.code(1324) + "Multiple values for parameter " + theParamName 233 + " is not supported in patient compartment mode"); 234 } 235 236 return idParts; 237 } 238 239 /** 240 * Return a partition or throw an error for FHIR operations that can not be used with this interceptor 241 */ 242 protected RequestPartitionId provideNonPatientSpecificQueryResponse() { 243 return RequestPartitionId.allPartitions(); 244 } 245 246 /** 247 * Generate the partition for a given patient resource ID. This method may be overridden in subclasses, but it 248 * may be easier to override {@link #providePartitionIdForPatientId(RequestDetails, String)} instead. 249 */ 250 @Nonnull 251 protected RequestPartitionId provideCompartmentMemberInstanceResponse( 252 RequestDetails theRequestDetails, String theResourceIdPart) { 253 int partitionId = providePartitionIdForPatientId(theRequestDetails, theResourceIdPart); 254 return RequestPartitionId.fromPartitionIdAndName(partitionId, theResourceIdPart); 255 } 256 257 /** 258 * Translates an ID (e.g. "ABC") into a compartment ID number. 259 * <p> 260 * The default implementation of this method returns: 261 * <code>Math.abs(theResourceIdPart.hashCode()) % 15000</code>. 262 * <p> 263 * This logic can be replaced with other logic of your choosing. 264 */ 265 @SuppressWarnings("unused") 266 protected int providePartitionIdForPatientId(RequestDetails theRequestDetails, String theResourceIdPart) { 267 return Math.abs(theResourceIdPart.hashCode() % 15000); 268 } 269 270 /** 271 * Return a compartment ID (or throw an exception) when an attempt is made to search for a resource that is 272 * in the patient compartment, but without any search parameter identifying which compartment to search. 273 * <p> 274 * E.g. this method will be called for the search <code>Observation?code=foo</code> since the patient 275 * is not identified in the URL. 276 */ 277 @Nonnull 278 protected RequestPartitionId throwNonCompartmentMemberInstanceFailureResponse(IBaseResource theResource) { 279 throw new MethodNotAllowedException(Msg.code(1326) + "Resource of type " 280 + myFhirContext.getResourceType(theResource) + " has no values placing it in the Patient compartment"); 281 } 282 283 /** 284 * Return a compartment ID (or throw an exception) when storing/reading resource types that 285 * are not in the patient compartment (e.g. ValueSet). 286 */ 287 @SuppressWarnings("unused") 288 @Nonnull 289 protected RequestPartitionId provideNonCompartmentMemberTypeResponse(IBaseResource theResource) { 290 return RequestPartitionId.fromPartitionId(myPartitionSettings.getDefaultPartitionId()); 291 } 292}