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