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