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