
001package ca.uhn.fhir.jpa.interceptor; 002 003/*- 004 * #%L 005 * HAPI FHIR JPA Server 006 * %% 007 * Copyright (C) 2014 - 2022 Smile CDR, Inc. 008 * %% 009 * Licensed under the Apache License, Version 2.0 (the "License"); 010 * you may not use this file except in compliance with the License. 011 * You may obtain a copy of the License at 012 * 013 * http://www.apache.org/licenses/LICENSE-2.0 014 * 015 * Unless required by applicable law or agreed to in writing, software 016 * distributed under the License is distributed on an "AS IS" BASIS, 017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 018 * See the License for the specific language governing permissions and 019 * limitations under the License. 020 * #L% 021 */ 022 023import ca.uhn.fhir.i18n.Msg; 024import ca.uhn.fhir.context.FhirContext; 025import ca.uhn.fhir.context.RuntimeResourceDefinition; 026import ca.uhn.fhir.context.RuntimeSearchParam; 027import ca.uhn.fhir.interceptor.api.Hook; 028import ca.uhn.fhir.interceptor.api.Interceptor; 029import ca.uhn.fhir.interceptor.api.Pointcut; 030import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails; 031import ca.uhn.fhir.interceptor.model.RequestPartitionId; 032import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; 033import ca.uhn.fhir.jpa.searchparam.extractor.BaseSearchParamExtractor; 034import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor; 035import ca.uhn.fhir.model.api.IQueryParameterType; 036import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; 037import ca.uhn.fhir.rest.api.server.RequestDetails; 038import ca.uhn.fhir.rest.param.ReferenceParam; 039import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException; 040import org.apache.commons.lang3.StringUtils; 041import org.hl7.fhir.instance.model.api.IBaseReference; 042import org.hl7.fhir.instance.model.api.IBaseResource; 043import org.hl7.fhir.r4.model.IdType; 044import org.springframework.beans.factory.annotation.Autowired; 045 046import javax.annotation.Nonnull; 047import java.util.Arrays; 048import java.util.List; 049import java.util.stream.Collectors; 050 051import static org.apache.commons.lang3.StringUtils.isBlank; 052import static org.apache.commons.lang3.StringUtils.isNotBlank; 053 054/** 055 * This interceptor allows JPA servers to be partitioned by Patient ID. It selects the compartment for read/create operations 056 * based on the patient ID associated with the resource (and uses a default partition ID for any resources 057 * not in the patient compartment). 058 */ 059@Interceptor 060public class PatientIdPartitionInterceptor { 061 062 @Autowired 063 private FhirContext myFhirContext; 064 065 @Autowired 066 private ISearchParamExtractor mySearchParamExtractor; 067 068 /** 069 * Constructor 070 */ 071 public PatientIdPartitionInterceptor() { 072 super(); 073 } 074 075 /** 076 * Constructor 077 */ 078 public PatientIdPartitionInterceptor(FhirContext theFhirContext, ISearchParamExtractor theSearchParamExtractor) { 079 this(); 080 myFhirContext = theFhirContext; 081 mySearchParamExtractor = theSearchParamExtractor; 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 = getCompartmentSearchParams(resourceDef); 088 if (compartmentSps.isEmpty()) { 089 return provideNonCompartmentMemberTypeResponse(theResource); 090 } 091 092 String compartmentIdentity; 093 if (resourceDef.getName().equals("Patient")) { 094 compartmentIdentity = theResource.getIdElement().getIdPart(); 095 if (isBlank(compartmentIdentity)) { 096 throw new MethodNotAllowedException(Msg.code(1321) + "Patient resource IDs must be client-assigned in patient compartment mode"); 097 } 098 } else { 099 compartmentIdentity = compartmentSps 100 .stream() 101 .flatMap(param -> Arrays.stream(BaseSearchParamExtractor.splitPathsR4(param.getPath()))) 102 .filter(StringUtils::isNotBlank) 103 .map(path -> mySearchParamExtractor.getPathValueExtractor(theResource, path).get()) 104 .filter(t -> !t.isEmpty()) 105 .map(t -> t.get(0)) 106 .filter(t -> t instanceof IBaseReference) 107 .map(t -> (IBaseReference) t) 108 .map(t -> t.getReferenceElement().getValue()) 109 .map(t -> new IdType(t).getIdPart()) 110 .filter(StringUtils::isNotBlank) 111 .findFirst() 112 .orElse(null); 113 if (isBlank(compartmentIdentity)) { 114 return provideNonCompartmentMemberInstanceResponse(theResource); 115 } 116 } 117 118 119 return provideCompartmentMemberInstanceResponse(theRequestDetails, compartmentIdentity); 120 } 121 122 @Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_READ) 123 public RequestPartitionId identifyForRead(ReadPartitionIdRequestDetails theReadDetails, RequestDetails theRequestDetails) { 124 RuntimeResourceDefinition resourceDef = myFhirContext.getResourceDefinition(theReadDetails.getResourceType()); 125 List<RuntimeSearchParam> compartmentSps = getCompartmentSearchParams(resourceDef); 126 if (compartmentSps.isEmpty()) { 127 return provideNonCompartmentMemberTypeResponse(null); 128 } 129 130 //noinspection EnumSwitchStatementWhichMissesCases 131 switch (theReadDetails.getRestOperationType()) { 132 case READ: 133 case VREAD: 134 if ("Patient".equals(theReadDetails.getResourceType())) { 135 return provideCompartmentMemberInstanceResponse(theRequestDetails, theReadDetails.getReadResourceId().getIdPart()); 136 } 137 break; 138 case SEARCH_TYPE: 139 SearchParameterMap params = (SearchParameterMap) theReadDetails.getSearchParams(); 140 String idPart = null; 141 if ("Patient".equals(theReadDetails.getResourceType())) { 142 idPart = getSingleResourceIdValueOrNull(params, "_id", "Patient"); 143 } else { 144 for (RuntimeSearchParam nextCompartmentSp : compartmentSps) { 145 idPart = getSingleResourceIdValueOrNull(params, nextCompartmentSp.getName(), "Patient"); 146 if (idPart != null) { 147 break; 148 } 149 } 150 } 151 152 if (isNotBlank(idPart)) { 153 return provideCompartmentMemberInstanceResponse(theRequestDetails, idPart); 154 } 155 156 break; 157 158 default: 159 // nothing 160 } 161 162 // If we couldn't identify a patient ID by the URL, let's try using the 163 // conditional target if we have one 164 if (theReadDetails.getConditionalTargetOrNull() != null) { 165 return identifyForCreate(theReadDetails.getConditionalTargetOrNull(), theRequestDetails); 166 } 167 168 return provideNonPatientSpecificQueryResponse(theReadDetails); 169 } 170 171 @Nonnull 172 private List<RuntimeSearchParam> getCompartmentSearchParams(RuntimeResourceDefinition resourceDef) { 173 return resourceDef 174 .getSearchParams() 175 .stream() 176 .filter(param -> param.getParamType() == RestSearchParameterTypeEnum.REFERENCE) 177 .filter(param -> param.getProvidesMembershipInCompartments() != null && param.getProvidesMembershipInCompartments().contains("Patient")) 178 .collect(Collectors.toList()); 179 } 180 181 private String getSingleResourceIdValueOrNull(SearchParameterMap theParams, String theParamName, String theResourceType) { 182 String idPart = null; 183 List<List<IQueryParameterType>> idParamAndList = theParams.get(theParamName); 184 if (idParamAndList != null && idParamAndList.size() == 1) { 185 List<IQueryParameterType> idParamOrList = idParamAndList.get(0); 186 if (idParamOrList.size() == 1) { 187 IQueryParameterType idParam = idParamOrList.get(0); 188 if (isNotBlank(idParam.getQueryParameterQualifier())) { 189 throw new MethodNotAllowedException(Msg.code(1322) + "The parameter " + theParamName + 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 + " is not supported in patient compartment mode"); 195 } 196 } 197 198 IdType id = new IdType(idParam.getValueAsQueryToken(myFhirContext)); 199 if (!id.hasResourceType() || id.getResourceType().equals(theResourceType)) { 200 idPart = id.getIdPart(); 201 } 202 } else if (idParamOrList.size() > 1) { 203 throw new MethodNotAllowedException(Msg.code(1324) + "Multiple values for parameter " + theParamName + " is not supported in patient compartment mode"); 204 } 205 } else if (idParamAndList != null && idParamAndList.size() > 1) { 206 throw new MethodNotAllowedException(Msg.code(1325) + "Multiple values for parameter " + theParamName + " is not supported in patient compartment mode"); 207 } 208 return idPart; 209 } 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(ReadPartitionIdRequestDetails theRequestDetails) { 216 return RequestPartitionId.allPartitions(); 217 } 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(RequestDetails theRequestDetails, String theResourceIdPart) { 226 int partitionId = providePartitionIdForPatientId(theRequestDetails, theResourceIdPart); 227 return RequestPartitionId.fromPartitionId(partitionId); 228 } 229 230 /** 231 * Translates an ID (e.g. "ABC") into a compartment ID number. 232 * <p> 233 * The default implementation of this method returns: 234 * <code>Math.abs(theResourceIdPart.hashCode()) % 15000</code>. 235 * <p> 236 * This logic can be replaced with other logic of your choosing. 237 */ 238 @SuppressWarnings("unused") 239 protected int providePartitionIdForPatientId(RequestDetails theRequestDetails, String theResourceIdPart) { 240 return Math.abs(theResourceIdPart.hashCode() % 15000); 241 } 242 243 /** 244 * Return a compartment ID (or throw an exception) when an attempt is made to search for a resource that is 245 * in the patient compartment, but without any search parameter identifying which compartment to search. 246 * <p> 247 * E.g. this method will be called for the search <code>Observation?code=foo</code> since the patient 248 * is not identified in the URL. 249 */ 250 @Nonnull 251 protected RequestPartitionId provideNonCompartmentMemberInstanceResponse(IBaseResource theResource) { 252 throw new MethodNotAllowedException(Msg.code(1326) + "Resource of type " + myFhirContext.getResourceType(theResource) + " has no values placing it in the Patient compartment"); 253 } 254 255 /** 256 * Return a compartment ID (or throw an exception) when storing/reading resource types that 257 * are not in the patient compartment (e.g. ValueSet). 258 */ 259 @SuppressWarnings("unused") 260 @Nonnull 261 protected RequestPartitionId provideNonCompartmentMemberTypeResponse(IBaseResource theResource) { 262 return RequestPartitionId.defaultPartition(); 263 } 264 265 266}