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