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