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.partition; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.context.RuntimeResourceDefinition; 024import ca.uhn.fhir.i18n.Msg; 025import ca.uhn.fhir.interceptor.api.HookParams; 026import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; 027import ca.uhn.fhir.interceptor.api.Pointcut; 028import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails; 029import ca.uhn.fhir.interceptor.model.RequestPartitionId; 030import ca.uhn.fhir.jpa.model.config.PartitionSettings; 031import ca.uhn.fhir.jpa.model.util.JpaConstants; 032import ca.uhn.fhir.rest.api.server.RequestDetails; 033import ca.uhn.fhir.rest.api.server.SystemRequestDetails; 034import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; 035import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; 036import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 037import jakarta.annotation.Nonnull; 038import jakarta.annotation.Nullable; 039import org.apache.commons.lang3.StringUtils; 040import org.hl7.fhir.instance.model.api.IBaseResource; 041import org.slf4j.Logger; 042import org.slf4j.LoggerFactory; 043import org.springframework.beans.factory.annotation.Autowired; 044 045import java.util.Arrays; 046import java.util.HashSet; 047import java.util.List; 048import java.util.Set; 049import java.util.stream.Collectors; 050 051import static ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster.doCallHooks; 052import static ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster.doCallHooksAndReturnObject; 053import static ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster.hasHooks; 054 055public abstract class BaseRequestPartitionHelperSvc implements IRequestPartitionHelperSvc { 056 private static final Logger ourLog = LoggerFactory.getLogger(BaseRequestPartitionHelperSvc.class); 057 058 private final HashSet<Object> myNonPartitionableResourceNames; 059 060 @Autowired 061 protected FhirContext myFhirContext; 062 063 @Autowired 064 private IInterceptorBroadcaster myInterceptorBroadcaster; 065 066 @Autowired 067 private PartitionSettings myPartitionSettings; 068 069 public BaseRequestPartitionHelperSvc() { 070 myNonPartitionableResourceNames = new HashSet<>(); 071 072 // Infrastructure 073 myNonPartitionableResourceNames.add("SearchParameter"); 074 075 // Validation and Conformance 076 myNonPartitionableResourceNames.add("StructureDefinition"); 077 myNonPartitionableResourceNames.add("Questionnaire"); 078 myNonPartitionableResourceNames.add("CapabilityStatement"); 079 myNonPartitionableResourceNames.add("CompartmentDefinition"); 080 myNonPartitionableResourceNames.add("OperationDefinition"); 081 082 myNonPartitionableResourceNames.add("Library"); 083 084 // Terminology 085 myNonPartitionableResourceNames.add("ConceptMap"); 086 myNonPartitionableResourceNames.add("CodeSystem"); 087 myNonPartitionableResourceNames.add("ValueSet"); 088 myNonPartitionableResourceNames.add("NamingSystem"); 089 myNonPartitionableResourceNames.add("StructureMap"); 090 } 091 092 /** 093 * Invoke the {@link Pointcut#STORAGE_PARTITION_IDENTIFY_READ} interceptor pointcut to determine the tenant for a read request. 094 * <p> 095 * If no interceptors are registered with a hook for {@link Pointcut#STORAGE_PARTITION_IDENTIFY_READ}, return 096 * {@link RequestPartitionId#allPartitions()} instead. 097 */ 098 @Nonnull 099 @Override 100 public RequestPartitionId determineReadPartitionForRequest( 101 @Nullable RequestDetails theRequest, @Nonnull ReadPartitionIdRequestDetails theDetails) { 102 if (!myPartitionSettings.isPartitioningEnabled()) { 103 return RequestPartitionId.allPartitions(); 104 } 105 106 // certain use-cases (e.g. batch2 jobs), only have resource type populated in the ReadPartitionIdRequestDetails 107 // TODO MM: see if we can make RequestDetails consistent 108 String resourceType = theDetails.getResourceType(); 109 110 RequestDetails requestDetails = theRequest; 111 // TODO GGG eventually, theRequest will not be allowed to be null here, and we will pass through 112 // SystemRequestDetails instead. 113 if (requestDetails == null) { 114 requestDetails = new SystemRequestDetails(); 115 } 116 117 boolean nonPartitionableResource = isResourceNonPartitionable(resourceType); 118 119 RequestPartitionId requestPartitionId = null; 120 // Handle system requests 121 if (requestDetails instanceof SystemRequestDetails 122 && systemRequestHasExplicitPartition((SystemRequestDetails) requestDetails) 123 && !nonPartitionableResource) { 124 requestPartitionId = getSystemRequestPartitionId((SystemRequestDetails) requestDetails, false); 125 } else if ((requestDetails instanceof SystemRequestDetails) && nonPartitionableResource) { 126 requestPartitionId = RequestPartitionId.fromPartitionId(myPartitionSettings.getDefaultPartitionId()); 127 } else if (hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, myInterceptorBroadcaster, requestDetails)) { 128 // Interceptor call: STORAGE_PARTITION_IDENTIFY_ANY 129 HookParams params = new HookParams() 130 .add(RequestDetails.class, requestDetails) 131 .addIfMatchesType(ServletRequestDetails.class, requestDetails); 132 requestPartitionId = (RequestPartitionId) doCallHooksAndReturnObject( 133 myInterceptorBroadcaster, requestDetails, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, params); 134 } else if (hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_READ, myInterceptorBroadcaster, requestDetails)) { 135 // Interceptor call: STORAGE_PARTITION_IDENTIFY_READ 136 HookParams params = new HookParams() 137 .add(RequestDetails.class, requestDetails) 138 .addIfMatchesType(ServletRequestDetails.class, requestDetails) 139 .add(ReadPartitionIdRequestDetails.class, theDetails); 140 requestPartitionId = (RequestPartitionId) doCallHooksAndReturnObject( 141 myInterceptorBroadcaster, requestDetails, Pointcut.STORAGE_PARTITION_IDENTIFY_READ, params); 142 } 143 144 validateRequestPartitionNotNull( 145 requestPartitionId, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, Pointcut.STORAGE_PARTITION_IDENTIFY_READ); 146 147 return validateAndNormalizePartition(requestPartitionId, requestDetails, resourceType); 148 } 149 150 @Override 151 public RequestPartitionId determineGenericPartitionForRequest(RequestDetails theRequestDetails) { 152 RequestPartitionId requestPartitionId = null; 153 154 if (!myPartitionSettings.isPartitioningEnabled()) { 155 return RequestPartitionId.allPartitions(); 156 } 157 158 if (theRequestDetails instanceof SystemRequestDetails 159 && systemRequestHasExplicitPartition((SystemRequestDetails) theRequestDetails)) { 160 requestPartitionId = getSystemRequestPartitionId((SystemRequestDetails) theRequestDetails); 161 } else if (hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, myInterceptorBroadcaster, theRequestDetails)) { 162 // Interceptor call: STORAGE_PARTITION_IDENTIFY_ANY 163 HookParams params = new HookParams() 164 .add(RequestDetails.class, theRequestDetails) 165 .addIfMatchesType(ServletRequestDetails.class, theRequestDetails); 166 requestPartitionId = (RequestPartitionId) doCallHooksAndReturnObject( 167 myInterceptorBroadcaster, theRequestDetails, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, params); 168 } 169 170 // TODO MM: at the moment it is ok for this method to return null 171 // check if it can be made consistent and it's implications 172 // validateRequestPartitionNotNull(requestPartitionId, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY); 173 174 if (requestPartitionId != null) { 175 return validateAndNormalizePartition( 176 requestPartitionId, theRequestDetails, theRequestDetails.getResourceName()); 177 } 178 return null; 179 } 180 181 /** 182 * For system requests, read partition from tenant ID if present, otherwise set to DEFAULT. If the resource they are attempting to partition 183 * is non-partitionable scream in the logs and set the partition to DEFAULT. 184 */ 185 private RequestPartitionId getSystemRequestPartitionId( 186 SystemRequestDetails theRequest, boolean theNonPartitionableResource) { 187 RequestPartitionId requestPartitionId; 188 requestPartitionId = getSystemRequestPartitionId(theRequest); 189 if (theNonPartitionableResource && !requestPartitionId.isDefaultPartition()) { 190 throw new InternalErrorException(Msg.code(1315) 191 + "System call is attempting to write a non-partitionable resource to a partition! This is a bug!"); 192 } 193 return requestPartitionId; 194 } 195 196 /** 197 * Determine the partition for a System Call (defined by the fact that the request is of type SystemRequestDetails) 198 * <p> 199 * 1. If the tenant ID is set to the constant for all partitions, return all partitions 200 * 2. If there is a tenant ID set in the request, use it. 201 * 3. Otherwise, return the Default Partition. 202 * 203 * @param theRequest The {@link SystemRequestDetails} 204 * @return the {@link RequestPartitionId} to be used for this request. 205 */ 206 @Nonnull 207 private RequestPartitionId getSystemRequestPartitionId(@Nonnull SystemRequestDetails theRequest) { 208 if (theRequest.getRequestPartitionId() != null) { 209 return theRequest.getRequestPartitionId(); 210 } 211 if (theRequest.getTenantId() != null) { 212 // TODO: JA2 we should not be inferring the partition name from the tenant name 213 return RequestPartitionId.fromPartitionName(theRequest.getTenantId()); 214 } else { 215 return RequestPartitionId.defaultPartition(); 216 } 217 } 218 219 /** 220 * Invoke the {@link Pointcut#STORAGE_PARTITION_IDENTIFY_CREATE} interceptor pointcut to determine the tenant for a create request. 221 */ 222 @Nonnull 223 @Override 224 public RequestPartitionId determineCreatePartitionForRequest( 225 @Nullable final RequestDetails theRequest, 226 @Nonnull IBaseResource theResource, 227 @Nonnull String theResourceType) { 228 229 if (!myPartitionSettings.isPartitioningEnabled()) { 230 return RequestPartitionId.allPartitions(); 231 } 232 233 RequestDetails requestDetails = theRequest; 234 boolean nonPartitionableResource = isResourceNonPartitionable(theResourceType); 235 236 // TODO GGG eventually, theRequest will not be allowed to be null here, and we will pass through 237 // SystemRequestDetails instead. 238 if (theRequest == null) { 239 requestDetails = new SystemRequestDetails(); 240 } 241 242 RequestPartitionId requestPartitionId = null; 243 if (theRequest instanceof SystemRequestDetails 244 && systemRequestHasExplicitPartition((SystemRequestDetails) theRequest)) { 245 requestPartitionId = 246 getSystemRequestPartitionId((SystemRequestDetails) theRequest, nonPartitionableResource); 247 } else if (hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, myInterceptorBroadcaster, requestDetails)) { 248 // Interceptor call: STORAGE_PARTITION_IDENTIFY_ANY 249 HookParams params = new HookParams() 250 .add(RequestDetails.class, requestDetails) 251 .addIfMatchesType(ServletRequestDetails.class, requestDetails); 252 requestPartitionId = (RequestPartitionId) doCallHooksAndReturnObject( 253 myInterceptorBroadcaster, requestDetails, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, params); 254 } else if (hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE, myInterceptorBroadcaster, requestDetails)) { 255 // Interceptor call: STORAGE_PARTITION_IDENTIFY_CREATE 256 HookParams params = new HookParams() 257 .add(IBaseResource.class, theResource) 258 .add(RequestDetails.class, requestDetails) 259 .addIfMatchesType(ServletRequestDetails.class, requestDetails); 260 requestPartitionId = (RequestPartitionId) doCallHooksAndReturnObject( 261 myInterceptorBroadcaster, requestDetails, Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE, params); 262 } 263 264 // If the interceptors haven't selected a partition, and its a non-partitionable resource anyhow, send 265 // to DEFAULT 266 if (nonPartitionableResource && requestPartitionId == null) { 267 requestPartitionId = RequestPartitionId.defaultPartition(); 268 } 269 270 validateRequestPartitionNotNull( 271 requestPartitionId, 272 Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE, 273 Pointcut.STORAGE_PARTITION_IDENTIFY_ANY); 274 validatePartitionForCreate(requestPartitionId, theResourceType); 275 276 return validateAndNormalizePartition(requestPartitionId, requestDetails, theResourceType); 277 } 278 279 private boolean systemRequestHasExplicitPartition(@Nonnull SystemRequestDetails theRequest) { 280 return theRequest.getRequestPartitionId() != null || theRequest.getTenantId() != null; 281 } 282 283 @Nonnull 284 @Override 285 public Set<Integer> toReadPartitions(@Nonnull RequestPartitionId theRequestPartitionId) { 286 return theRequestPartitionId.getPartitionIds().stream() 287 .map(t -> t == null ? myPartitionSettings.getDefaultPartitionId() : t) 288 .collect(Collectors.toSet()); 289 } 290 291 /** 292 * If the partition only has a name but not an ID, this method resolves the ID. 293 * <p> 294 * If the partition has an ID but not a name, the name is resolved. 295 * <p> 296 * If the partition has both, they are validated to ensure that they correspond. 297 */ 298 @Nonnull 299 private RequestPartitionId validateAndNormalizePartition( 300 @Nonnull RequestPartitionId theRequestPartitionId, 301 RequestDetails theRequest, 302 @Nullable String theResourceType) { 303 RequestPartitionId retVal = theRequestPartitionId; 304 305 if (!myPartitionSettings.isUnnamedPartitionMode()) { 306 if (retVal.getPartitionNames() != null) { 307 retVal = validateAndNormalizePartitionNames(retVal); 308 } else if (retVal.hasPartitionIds()) { 309 retVal = validateAndNormalizePartitionIds(retVal); 310 } 311 } 312 313 // Note: It's still possible that the partition only has a date but no name/id 314 315 if (StringUtils.isNotBlank(theResourceType)) { 316 validateHasPartitionPermissions(theRequest, theResourceType, retVal); 317 } 318 319 return retVal; 320 } 321 322 @Override 323 public void validateHasPartitionPermissions( 324 @Nonnull RequestDetails theRequest, String theResourceType, RequestPartitionId theRequestPartitionId) { 325 if (myInterceptorBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_SELECTED)) { 326 RuntimeResourceDefinition runtimeResourceDefinition = null; 327 if (theResourceType != null) { 328 runtimeResourceDefinition = myFhirContext.getResourceDefinition(theResourceType); 329 } 330 HookParams params = new HookParams() 331 .add(RequestPartitionId.class, theRequestPartitionId) 332 .add(RequestDetails.class, theRequest) 333 .addIfMatchesType(ServletRequestDetails.class, theRequest) 334 .add(RuntimeResourceDefinition.class, runtimeResourceDefinition); 335 doCallHooks(myInterceptorBroadcaster, theRequest, Pointcut.STORAGE_PARTITION_SELECTED, params); 336 } 337 } 338 339 @Override 340 public boolean isResourcePartitionable(String theResourceType) { 341 return theResourceType != null && !myNonPartitionableResourceNames.contains(theResourceType); 342 } 343 344 private boolean isResourceNonPartitionable(String theResourceType) { 345 return theResourceType != null && !isResourcePartitionable(theResourceType); 346 } 347 348 private void validatePartitionForCreate(RequestPartitionId theRequestPartitionId, String theResourceName) { 349 if (theRequestPartitionId.hasPartitionIds()) { 350 validateSinglePartitionIdOrName(theRequestPartitionId.getPartitionIds()); 351 } 352 validateSinglePartitionIdOrName(theRequestPartitionId.getPartitionNames()); 353 354 // Make sure we're not using one of the conformance resources in a non-default partition 355 if (theRequestPartitionId.isDefaultPartition() || theRequestPartitionId.isAllPartitions()) { 356 return; 357 } 358 359 // TODO MM: check if we need to validate using the configured value PartitionSettings.defaultPartition 360 // however that is only used for read and not for create at the moment 361 if ((theRequestPartitionId.hasPartitionIds() 362 && !theRequestPartitionId.getPartitionIds().contains(null)) 363 || (theRequestPartitionId.hasPartitionNames() 364 && !theRequestPartitionId.getPartitionNames().contains(JpaConstants.DEFAULT_PARTITION_NAME))) { 365 366 if (isResourceNonPartitionable(theResourceName)) { 367 String msg = myFhirContext 368 .getLocalizer() 369 .getMessageSanitized( 370 BaseRequestPartitionHelperSvc.class, 371 "nonDefaultPartitionSelectedForNonPartitionable", 372 theResourceName); 373 throw new UnprocessableEntityException(Msg.code(1318) + msg); 374 } 375 } 376 } 377 378 private static void validateRequestPartitionNotNull( 379 RequestPartitionId theRequestPartitionId, Pointcut... thePointcuts) { 380 if (theRequestPartitionId == null) { 381 throw new InternalErrorException( 382 Msg.code(1319) + "No interceptor provided a value for pointcuts: " + Arrays.toString(thePointcuts)); 383 } 384 } 385 386 private static void validateSinglePartitionIdOrName(@Nullable List<?> thePartitionIds) { 387 if (thePartitionIds != null && thePartitionIds.size() != 1) { 388 throw new InternalErrorException( 389 Msg.code(1320) + "RequestPartitionId must contain a single partition for create operations, found: " 390 + thePartitionIds); 391 } 392 } 393}