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