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