001package ca.uhn.fhir.jpa.partition;
002
003/*-
004 * #%L
005 * HAPI FHIR JPA Server
006 * %%
007 * Copyright (C) 2014 - 2021 Smile CDR, Inc.
008 * %%
009 * Licensed under the Apache License, Version 2.0 (the "License");
010 * you may not use this file except in compliance with the License.
011 * You may obtain a copy of the License at
012 *
013 *      http://www.apache.org/licenses/LICENSE-2.0
014 *
015 * Unless required by applicable law or agreed to in writing, software
016 * distributed under the License is distributed on an "AS IS" BASIS,
017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
018 * See the License for the specific language governing permissions and
019 * limitations under the License.
020 * #L%
021 */
022
023import ca.uhn.fhir.context.FhirContext;
024import ca.uhn.fhir.context.RuntimeResourceDefinition;
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.entity.PartitionEntity;
031import ca.uhn.fhir.jpa.model.config.PartitionSettings;
032import ca.uhn.fhir.jpa.model.entity.PartitionablePartitionId;
033import ca.uhn.fhir.jpa.model.util.JpaConstants;
034import ca.uhn.fhir.rest.api.server.RequestDetails;
035import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
036import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException;
037import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException;
038import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
039import org.apache.commons.lang3.Validate;
040import org.hl7.fhir.instance.model.api.IBaseResource;
041import org.slf4j.Logger;
042import org.springframework.beans.factory.annotation.Autowired;
043
044import javax.annotation.Nonnull;
045import javax.annotation.Nullable;
046import java.util.ArrayList;
047import java.util.HashSet;
048import java.util.List;
049import java.util.Objects;
050import java.util.Set;
051import java.util.stream.Collectors;
052
053import static ca.uhn.fhir.jpa.model.util.JpaConstants.ALL_PARTITIONS_NAME;
054import static ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster.doCallHooks;
055import static ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster.doCallHooksAndReturnObject;
056import static ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster.hasHooks;
057import static org.slf4j.LoggerFactory.getLogger;
058
059public class RequestPartitionHelperSvc implements IRequestPartitionHelperSvc {
060        private static final Logger ourLog = getLogger(RequestPartitionHelperSvc.class);
061
062
063        private final HashSet<Object> myNonPartitionableResourceNames;
064
065        @Autowired
066        private IInterceptorBroadcaster myInterceptorBroadcaster;
067        @Autowired
068        private IPartitionLookupSvc myPartitionConfigSvc;
069        @Autowired
070        private FhirContext myFhirContext;
071        @Autowired
072        private PartitionSettings myPartitionSettings;
073
074        public RequestPartitionHelperSvc() {
075                myNonPartitionableResourceNames = new HashSet<>();
076
077                // Infrastructure
078                myNonPartitionableResourceNames.add("Subscription");
079                myNonPartitionableResourceNames.add("SearchParameter");
080
081                // Validation and Conformance
082                myNonPartitionableResourceNames.add("StructureDefinition");
083                myNonPartitionableResourceNames.add("Questionnaire");
084                myNonPartitionableResourceNames.add("CapabilityStatement");
085                myNonPartitionableResourceNames.add("CompartmentDefinition");
086                myNonPartitionableResourceNames.add("OperationDefinition");
087
088                // Terminology
089                myNonPartitionableResourceNames.add("ConceptMap");
090                myNonPartitionableResourceNames.add("CodeSystem");
091                myNonPartitionableResourceNames.add("ValueSet");
092                myNonPartitionableResourceNames.add("NamingSystem");
093                myNonPartitionableResourceNames.add("StructureMap");
094
095        }
096
097        /**
098         * Invoke the {@link Pointcut#STORAGE_PARTITION_IDENTIFY_READ} interceptor pointcut to determine the tenant for a read request.
099         * <p>
100         * If no interceptors are registered with a hook for {@link Pointcut#STORAGE_PARTITION_IDENTIFY_READ}, return
101         * {@link RequestPartitionId#allPartitions()} instead.
102         */
103        @Nonnull
104        @Override
105        public RequestPartitionId determineReadPartitionForRequest(@Nullable RequestDetails theRequest, String theResourceType, ReadPartitionIdRequestDetails theDetails) {
106                RequestPartitionId requestPartitionId;
107
108                boolean nonPartitionableResource = myNonPartitionableResourceNames.contains(theResourceType);
109                if (myPartitionSettings.isPartitioningEnabled()) {
110                        // Handle system requests
111                        //TODO GGG eventually, theRequest will not be allowed to be null here, and we will pass through SystemRequestDetails instead.
112                        if ((theRequest == null || theRequest instanceof SystemRequestDetails) && nonPartitionableResource) {
113                                return RequestPartitionId.defaultPartition();
114                        }
115
116                        if (theRequest instanceof SystemRequestDetails && systemRequestHasExplicitPartition((SystemRequestDetails) theRequest)) {
117                                requestPartitionId = getSystemRequestPartitionId((SystemRequestDetails) theRequest, nonPartitionableResource);
118                        } else if (hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_READ, myInterceptorBroadcaster, theRequest)) {
119                                // Interceptor call: STORAGE_PARTITION_IDENTIFY_READ
120                                HookParams params = new HookParams()
121                                        .add(RequestDetails.class, theRequest)
122                                        .addIfMatchesType(ServletRequestDetails.class, theRequest)
123                                        .add(ReadPartitionIdRequestDetails.class, theDetails);
124                                requestPartitionId = (RequestPartitionId) doCallHooksAndReturnObject(myInterceptorBroadcaster, theRequest, Pointcut.STORAGE_PARTITION_IDENTIFY_READ, params);
125                        } else {
126                                requestPartitionId = null;
127                        }
128
129                        validateRequestPartitionNotNull(requestPartitionId, Pointcut.STORAGE_PARTITION_IDENTIFY_READ);
130
131                        return validateNormalizeAndNotifyHooksForRead(requestPartitionId, theRequest, theResourceType);
132                }
133
134                return RequestPartitionId.allPartitions();
135        }
136
137        /**
138         * For system requests, read partition from tenant ID if present, otherwise set to DEFAULT. If the resource they are attempting to partition
139         * is non-partitionable scream in the logs and set the partition to DEFAULT.
140         *
141         */
142        private RequestPartitionId getSystemRequestPartitionId(SystemRequestDetails theRequest, boolean theNonPartitionableResource) {
143                RequestPartitionId requestPartitionId;
144                requestPartitionId = getSystemRequestPartitionId(theRequest);
145                if (theNonPartitionableResource && !requestPartitionId.isDefaultPartition()) {
146                        throw new InternalErrorException("System call is attempting to write a non-partitionable resource to a partition! This is a bug!");
147                }
148                return requestPartitionId;
149        }
150
151        /**
152         * Determine the partition for a System Call (defined by the fact that the request is of type SystemRequestDetails)
153         * <p>
154         * 1. If the tenant ID is set to the constant for all partitions, return all partitions
155         * 2. If there is a tenant ID set in the request, use it.
156         * 3. Otherwise, return the Default Partition.
157         *
158         * @param theRequest The {@link SystemRequestDetails}
159         * @return the {@link RequestPartitionId} to be used for this request.
160         */
161        @Nonnull
162        private RequestPartitionId getSystemRequestPartitionId(@Nonnull SystemRequestDetails theRequest) {
163                if (theRequest.getRequestPartitionId() != null) {
164                        return theRequest.getRequestPartitionId();
165                }
166                if (theRequest.getTenantId() != null) {
167                        if (theRequest.getTenantId().equals(ALL_PARTITIONS_NAME)) {
168                                return RequestPartitionId.allPartitions();
169                        } else {
170                                return RequestPartitionId.fromPartitionName(theRequest.getTenantId());
171                        }
172                } else {
173                        return RequestPartitionId.defaultPartition();
174                }
175        }
176
177        /**
178         * Invoke the {@link Pointcut#STORAGE_PARTITION_IDENTIFY_CREATE} interceptor pointcut to determine the tenant for a create request.
179         */
180        @Nonnull
181        @Override
182        public RequestPartitionId determineCreatePartitionForRequest(@Nullable RequestDetails theRequest, @Nonnull IBaseResource theResource, @Nonnull String theResourceType) {
183                RequestPartitionId requestPartitionId;
184
185                if (myPartitionSettings.isPartitioningEnabled()) {
186                        boolean nonPartitionableResource = myNonPartitionableResourceNames.contains(theResourceType);
187
188                        //TODO GGG eventually, theRequest will not be allowed to be null here, and we will pass through SystemRequestDetails instead.
189                        if ((theRequest == null || theRequest instanceof SystemRequestDetails) && nonPartitionableResource) {
190                                return RequestPartitionId.defaultPartition();
191                        }
192
193                        if (theRequest instanceof SystemRequestDetails && systemRequestHasExplicitPartition((SystemRequestDetails) theRequest)) {
194                                requestPartitionId = getSystemRequestPartitionId((SystemRequestDetails) theRequest, nonPartitionableResource);
195                        } else {
196                                //This is an external Request (e.g. ServletRequestDetails) so we want to figure out the partition via interceptor.
197                                // Interceptor call: STORAGE_PARTITION_IDENTIFY_CREATE
198                                HookParams params = new HookParams()
199                                        .add(IBaseResource.class, theResource)
200                                        .add(RequestDetails.class, theRequest)
201                                        .addIfMatchesType(ServletRequestDetails.class, theRequest);
202                                requestPartitionId = (RequestPartitionId) doCallHooksAndReturnObject(myInterceptorBroadcaster, theRequest, Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE, params);
203
204                                //If the interceptors haven't selected a partition, and its a non-partitionable resource anyhow, send to DEFAULT
205                                if (nonPartitionableResource && requestPartitionId == null) {
206                                        requestPartitionId = RequestPartitionId.defaultPartition();
207                                }
208                        }
209
210                        String resourceName = myFhirContext.getResourceType(theResource);
211                        validateSinglePartitionForCreate(requestPartitionId, resourceName, Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE);
212
213                        return validateNormalizeAndNotifyHooksForRead(requestPartitionId, theRequest, theResourceType);
214                }
215
216                return RequestPartitionId.allPartitions();
217        }
218
219        private boolean systemRequestHasExplicitPartition(@Nonnull SystemRequestDetails theRequest) {
220                return theRequest.getRequestPartitionId() != null || theRequest.getTenantId() != null;
221        }
222
223        @Nonnull
224        @Override
225        public PartitionablePartitionId toStoragePartition(@Nonnull RequestPartitionId theRequestPartitionId) {
226                Integer partitionId = theRequestPartitionId.getFirstPartitionIdOrNull();
227                if (partitionId == null) {
228                        partitionId = myPartitionSettings.getDefaultPartitionId();
229                }
230                return new PartitionablePartitionId(partitionId, theRequestPartitionId.getPartitionDate());
231        }
232
233        @Nonnull
234        @Override
235        public Set<Integer> toReadPartitions(@Nonnull RequestPartitionId theRequestPartitionId) {
236                return theRequestPartitionId
237                        .getPartitionIds()
238                        .stream()
239                        .map(t->t == null ? myPartitionSettings.getDefaultPartitionId() : t)
240                        .collect(Collectors.toSet());
241        }
242
243        /**
244         * If the partition only has a name but not an ID, this method resolves the ID.
245         * <p>
246         * If the partition has an ID but not a name, the name is resolved.
247         * <p>
248         * If the partition has both, they are validated to ensure that they correspond.
249         */
250        @Nonnull
251        private RequestPartitionId validateNormalizeAndNotifyHooksForRead(@Nonnull RequestPartitionId theRequestPartitionId, RequestDetails theRequest, String theResourceType) {
252                RequestPartitionId retVal = theRequestPartitionId;
253
254                if (retVal.getPartitionNames() != null) {
255                        retVal = validateAndNormalizePartitionNames(retVal);
256                } else if (retVal.hasPartitionIds()) {
257                        retVal = validateAndNormalizePartitionIds(retVal);
258                }
259
260                // Note: It's still possible that the partition only has a date but no name/id
261
262                if (myInterceptorBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_SELECTED)) {
263                        RuntimeResourceDefinition runtimeResourceDefinition = myFhirContext.getResourceDefinition(theResourceType);
264                        HookParams params = new HookParams()
265                                .add(RequestPartitionId.class, retVal)
266                                .add(RequestDetails.class, theRequest)
267                                .addIfMatchesType(ServletRequestDetails.class, theRequest)
268                                .add(RuntimeResourceDefinition.class, runtimeResourceDefinition);
269                        doCallHooks(myInterceptorBroadcaster, theRequest, Pointcut.STORAGE_PARTITION_SELECTED, params);
270                }
271
272                return retVal;
273
274        }
275
276        private RequestPartitionId validateAndNormalizePartitionIds(RequestPartitionId theRequestPartitionId) {
277                List<String> names = null;
278                for (int i = 0; i < theRequestPartitionId.getPartitionIds().size(); i++) {
279
280                        PartitionEntity partition;
281                        Integer id = theRequestPartitionId.getPartitionIds().get(i);
282                        if (id == null) {
283                                partition = null;
284                        } else {
285                                try {
286                                        partition = myPartitionConfigSvc.getPartitionById(id);
287                                } catch (IllegalArgumentException e) {
288                                        String msg = myFhirContext.getLocalizer().getMessage(RequestPartitionHelperSvc.class, "unknownPartitionId", theRequestPartitionId.getPartitionIds().get(i));
289                                        throw new ResourceNotFoundException(msg);
290                                }
291                        }
292
293                        if (theRequestPartitionId.getPartitionNames() != null) {
294                                if (partition == null) {
295                                        Validate.isTrue(theRequestPartitionId.getPartitionIds().get(i) == null, "Partition %s must not have an ID", JpaConstants.DEFAULT_PARTITION_NAME);
296                                } else {
297                                        Validate.isTrue(Objects.equals(theRequestPartitionId.getPartitionIds().get(i), partition.getId()), "Partition name %s does not match ID %n", theRequestPartitionId.getPartitionNames().get(i), theRequestPartitionId.getPartitionIds().get(i));
298                                }
299                        } else {
300                                if (names == null) {
301                                        names = new ArrayList<>();
302                                }
303                                if (partition != null) {
304                                        names.add(partition.getName());
305                                } else {
306                                        names.add(null);
307                                }
308                        }
309
310                }
311
312                if (names != null) {
313                        return RequestPartitionId.forPartitionIdsAndNames(names, theRequestPartitionId.getPartitionIds(), theRequestPartitionId.getPartitionDate());
314                }
315
316                return theRequestPartitionId;
317        }
318
319        private RequestPartitionId validateAndNormalizePartitionNames(RequestPartitionId theRequestPartitionId) {
320                List<Integer> ids = null;
321                for (int i = 0; i < theRequestPartitionId.getPartitionNames().size(); i++) {
322
323                        PartitionEntity partition;
324                        try {
325                                partition = myPartitionConfigSvc.getPartitionByName(theRequestPartitionId.getPartitionNames().get(i));
326                        } catch (IllegalArgumentException e) {
327                                String msg = myFhirContext.getLocalizer().getMessage(RequestPartitionHelperSvc.class, "unknownPartitionName", theRequestPartitionId.getPartitionNames().get(i));
328                                throw new ResourceNotFoundException(msg);
329                        }
330
331                        if (theRequestPartitionId.hasPartitionIds()) {
332                                if (partition == null) {
333                                        Validate.isTrue(theRequestPartitionId.getPartitionIds().get(i) == null, "Partition %s must not have an ID", JpaConstants.DEFAULT_PARTITION_NAME);
334                                } else {
335                                        Validate.isTrue(Objects.equals(theRequestPartitionId.getPartitionIds().get(i), partition.getId()), "Partition name %s does not match ID %n", theRequestPartitionId.getPartitionNames().get(i), theRequestPartitionId.getPartitionIds().get(i));
336                                }
337                        } else {
338                                if (ids == null) {
339                                        ids = new ArrayList<>();
340                                }
341                                if (partition != null) {
342                                        ids.add(partition.getId());
343                                } else {
344                                        ids.add(null);
345                                }
346                        }
347
348                }
349
350                if (ids != null) {
351                        return RequestPartitionId.forPartitionIdsAndNames(theRequestPartitionId.getPartitionNames(), ids, theRequestPartitionId.getPartitionDate());
352                }
353
354                return theRequestPartitionId;
355        }
356
357        private void validateSinglePartitionForCreate(RequestPartitionId theRequestPartitionId, @Nonnull String theResourceName, Pointcut thePointcut) {
358                validateRequestPartitionNotNull(theRequestPartitionId, thePointcut);
359
360                if (theRequestPartitionId.hasPartitionIds()) {
361                        validateSinglePartitionIdOrNameForCreate(theRequestPartitionId.getPartitionIds());
362                }
363                validateSinglePartitionIdOrNameForCreate(theRequestPartitionId.getPartitionNames());
364
365                // Make sure we're not using one of the conformance resources in a non-default partition
366                if ((theRequestPartitionId.hasPartitionIds() && !theRequestPartitionId.getPartitionIds().contains(null)) ||
367                        (theRequestPartitionId.hasPartitionNames() && !theRequestPartitionId.getPartitionNames().contains(JpaConstants.DEFAULT_PARTITION_NAME))) {
368
369                        if (myNonPartitionableResourceNames.contains(theResourceName)) {
370                                String msg = myFhirContext.getLocalizer().getMessageSanitized(RequestPartitionHelperSvc.class, "nonDefaultPartitionSelectedForNonPartitionable", theResourceName);
371                                throw new UnprocessableEntityException(msg);
372                        }
373
374                }
375
376        }
377
378        private void validateRequestPartitionNotNull(RequestPartitionId theRequestPartitionId, Pointcut theThePointcut) {
379                if (theRequestPartitionId == null) {
380                        throw new InternalErrorException("No interceptor provided a value for pointcut: " + theThePointcut);
381                }
382        }
383
384        private void validateSinglePartitionIdOrNameForCreate(@Nullable List<?> thePartitionIds) {
385                if (thePartitionIds != null && thePartitionIds.size() != 1) {
386                        throw new InternalErrorException("RequestPartitionId must contain a single partition for create operations, found: " + thePartitionIds);
387                }
388        }
389}