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}