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