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