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