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.interceptor;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.context.RuntimeResourceDefinition;
024import ca.uhn.fhir.context.RuntimeSearchParam;
025import ca.uhn.fhir.i18n.Msg;
026import ca.uhn.fhir.interceptor.api.Hook;
027import ca.uhn.fhir.interceptor.api.Interceptor;
028import ca.uhn.fhir.interceptor.api.Pointcut;
029import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails;
030import ca.uhn.fhir.interceptor.model.RequestPartitionId;
031import ca.uhn.fhir.jpa.model.config.PartitionSettings;
032import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
033import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor;
034import ca.uhn.fhir.jpa.util.ResourceCompartmentUtil;
035import ca.uhn.fhir.model.api.IQueryParameterType;
036import ca.uhn.fhir.rest.api.server.RequestDetails;
037import ca.uhn.fhir.rest.param.ReferenceParam;
038import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException;
039import ca.uhn.fhir.rest.server.provider.ProviderConstants;
040import jakarta.annotation.Nonnull;
041import org.hl7.fhir.instance.model.api.IBaseResource;
042import org.hl7.fhir.instance.model.api.IIdType;
043import org.hl7.fhir.r4.model.IdType;
044import org.springframework.beans.factory.annotation.Autowired;
045
046import java.util.ArrayList;
047import java.util.Collection;
048import java.util.Collections;
049import java.util.List;
050import java.util.Optional;
051
052import static org.apache.commons.lang3.StringUtils.isBlank;
053import static org.apache.commons.lang3.StringUtils.isEmpty;
054import static org.apache.commons.lang3.StringUtils.isNotBlank;
055
056/**
057 * This interceptor allows JPA servers to be partitioned by Patient ID. It selects the compartment for read/create operations
058 * based on the patient ID associated with the resource (and uses a default partition ID for any resources
059 * not in the patient compartment).
060 */
061@Interceptor
062public class PatientIdPartitionInterceptor {
063
064        @Autowired
065        private FhirContext myFhirContext;
066
067        @Autowired
068        private ISearchParamExtractor mySearchParamExtractor;
069
070        @Autowired
071        private PartitionSettings myPartitionSettings;
072
073        /**
074         * Constructor
075         */
076        public PatientIdPartitionInterceptor(
077                        FhirContext theFhirContext,
078                        ISearchParamExtractor theSearchParamExtractor,
079                        PartitionSettings thePartitionSettings) {
080                myFhirContext = theFhirContext;
081                mySearchParamExtractor = theSearchParamExtractor;
082                myPartitionSettings = thePartitionSettings;
083        }
084
085        @Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE)
086        public RequestPartitionId identifyForCreate(IBaseResource theResource, RequestDetails theRequestDetails) {
087                RuntimeResourceDefinition resourceDef = myFhirContext.getResourceDefinition(theResource);
088                List<RuntimeSearchParam> compartmentSps =
089                                ResourceCompartmentUtil.getPatientCompartmentSearchParams(resourceDef);
090                if (compartmentSps.isEmpty()) {
091                        return provideNonCompartmentMemberTypeResponse(theResource);
092                }
093
094                Optional<String> oCompartmentIdentity;
095                if (resourceDef.getName().equals("Patient")) {
096                        IIdType idElement = theResource.getIdElement();
097                        oCompartmentIdentity = Optional.ofNullable(idElement.getIdPart());
098                        if (idElement.isUuid() || oCompartmentIdentity.isEmpty()) {
099                                throw new MethodNotAllowedException(
100                                                Msg.code(1321) + "Patient resource IDs must be client-assigned in patient compartment mode");
101                        }
102                } else {
103                        oCompartmentIdentity = ResourceCompartmentUtil.getResourceCompartment(
104                                        "Patient", theResource, compartmentSps, mySearchParamExtractor);
105                }
106
107                return oCompartmentIdentity
108                                .map(ci -> provideCompartmentMemberInstanceResponse(theRequestDetails, ci))
109                                .orElseGet(() -> provideNonCompartmentMemberInstanceResponse(theResource));
110        }
111
112        @Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_READ)
113        public RequestPartitionId identifyForRead(
114                        @Nonnull ReadPartitionIdRequestDetails theReadDetails, RequestDetails theRequestDetails) {
115                List<RuntimeSearchParam> compartmentSps = Collections.emptyList();
116                if (!isEmpty(theReadDetails.getResourceType())) {
117                        RuntimeResourceDefinition resourceDef =
118                                        myFhirContext.getResourceDefinition(theReadDetails.getResourceType());
119                        compartmentSps = ResourceCompartmentUtil.getPatientCompartmentSearchParams(resourceDef);
120                        if (compartmentSps.isEmpty()) {
121                                return provideNonCompartmentMemberTypeResponse(null);
122                        }
123                }
124
125                //noinspection EnumSwitchStatementWhichMissesCases
126                switch (theReadDetails.getRestOperationType()) {
127                        case READ:
128                        case VREAD:
129                                if ("Patient".equals(theReadDetails.getResourceType())) {
130                                        return provideCompartmentMemberInstanceResponse(
131                                                        theRequestDetails,
132                                                        theReadDetails.getReadResourceId().getIdPart());
133                                }
134                                break;
135                        case SEARCH_TYPE:
136                                SearchParameterMap params = theReadDetails.getSearchParams();
137                                assert params != null;
138                                if ("Patient".equals(theReadDetails.getResourceType())) {
139                                        List<String> idParts = getResourceIdList(params, "_id", "Patient", false);
140                                        if (idParts.size() == 1) {
141                                                return provideCompartmentMemberInstanceResponse(theRequestDetails, idParts.get(0));
142                                        } else {
143                                                return RequestPartitionId.allPartitions();
144                                        }
145                                } else {
146                                        for (RuntimeSearchParam nextCompartmentSp : compartmentSps) {
147                                                List<String> idParts = getResourceIdList(params, nextCompartmentSp.getName(), "Patient", true);
148                                                if (!idParts.isEmpty()) {
149                                                        return provideCompartmentMemberInstanceResponse(theRequestDetails, idParts.get(0));
150                                                }
151                                        }
152                                }
153
154                                break;
155                        case EXTENDED_OPERATION_SERVER:
156                                String extendedOp = theReadDetails.getExtendedOperationName();
157                                if (ProviderConstants.OPERATION_EXPORT.equals(extendedOp)
158                                                || ProviderConstants.OPERATION_EXPORT_POLL_STATUS.equals(extendedOp)) {
159                                        return provideNonPatientSpecificQueryResponse(theReadDetails);
160                                }
161                                break;
162                        default:
163                                // nothing
164                }
165
166                if (isBlank(theReadDetails.getResourceType())) {
167                        return provideNonCompartmentMemberTypeResponse(null);
168                }
169
170                // If we couldn't identify a patient ID by the URL, let's try using the
171                // conditional target if we have one
172                if (theReadDetails.getConditionalTargetOrNull() != null) {
173                        return identifyForCreate(theReadDetails.getConditionalTargetOrNull(), theRequestDetails);
174                }
175
176                return provideNonPatientSpecificQueryResponse(theReadDetails);
177        }
178
179        private List<String> getResourceIdList(
180                        SearchParameterMap theParams, String theParamName, String theResourceType, boolean theExpectOnlyOneBool) {
181                List<List<IQueryParameterType>> idParamAndList = theParams.get(theParamName);
182                if (idParamAndList == null) {
183                        return Collections.emptyList();
184                }
185
186                List<String> idParts = new ArrayList<>();
187                idParamAndList.stream().flatMap(Collection::stream).forEach(idParam -> {
188                        if (isNotBlank(idParam.getQueryParameterQualifier())) {
189                                throw new MethodNotAllowedException(Msg.code(1322) + "The parameter " + theParamName
190                                                + idParam.getQueryParameterQualifier() + " is not supported in patient compartment mode");
191                        }
192                        if (idParam instanceof ReferenceParam) {
193                                String chain = ((ReferenceParam) idParam).getChain();
194                                if (chain != null) {
195                                        throw new MethodNotAllowedException(Msg.code(1323) + "The parameter " + theParamName + "." + chain
196                                                        + " is not supported in patient compartment mode");
197                                }
198                        }
199                        IdType id = new IdType(idParam.getValueAsQueryToken(myFhirContext));
200                        if (!id.hasResourceType() || id.getResourceType().equals(theResourceType)) {
201                                idParts.add(id.getIdPart());
202                        }
203                });
204
205                if (theExpectOnlyOneBool && idParts.size() > 1) {
206                        throw new MethodNotAllowedException(Msg.code(1324) + "Multiple values for parameter " + theParamName
207                                        + " is not supported in patient compartment mode");
208                }
209
210                return idParts;
211        }
212
213        /**
214         * Return a partition or throw an error for FHIR operations that can not be used with this interceptor
215         */
216        protected RequestPartitionId provideNonPatientSpecificQueryResponse(
217                        ReadPartitionIdRequestDetails theRequestDetails) {
218                return RequestPartitionId.allPartitions();
219        }
220
221        /**
222         * Generate the partition for a given patient resource ID. This method may be overridden in subclasses, but it
223         * may be easier to override {@link #providePartitionIdForPatientId(RequestDetails, String)} instead.
224         */
225        @Nonnull
226        protected RequestPartitionId provideCompartmentMemberInstanceResponse(
227                        RequestDetails theRequestDetails, String theResourceIdPart) {
228                int partitionId = providePartitionIdForPatientId(theRequestDetails, theResourceIdPart);
229                return RequestPartitionId.fromPartitionIdAndName(partitionId, theResourceIdPart);
230        }
231
232        /**
233         * Translates an ID (e.g. "ABC") into a compartment ID number.
234         * <p>
235         * The default implementation of this method returns:
236         * <code>Math.abs(theResourceIdPart.hashCode()) % 15000</code>.
237         * <p>
238         * This logic can be replaced with other logic of your choosing.
239         */
240        @SuppressWarnings("unused")
241        protected int providePartitionIdForPatientId(RequestDetails theRequestDetails, String theResourceIdPart) {
242                return Math.abs(theResourceIdPart.hashCode() % 15000);
243        }
244
245        /**
246         * Return a compartment ID (or throw an exception) when an attempt is made to search for a resource that is
247         * in the patient compartment, but without any search parameter identifying which compartment to search.
248         * <p>
249         * E.g. this method will be called for the search <code>Observation?code=foo</code> since the patient
250         * is not identified in the URL.
251         */
252        @Nonnull
253        protected RequestPartitionId provideNonCompartmentMemberInstanceResponse(IBaseResource theResource) {
254                throw new MethodNotAllowedException(Msg.code(1326) + "Resource of type "
255                                + myFhirContext.getResourceType(theResource) + " has no values placing it in the Patient compartment");
256        }
257
258        /**
259         * Return a compartment ID (or throw an exception) when storing/reading resource types that
260         * are not in the patient compartment (e.g. ValueSet).
261         */
262        @SuppressWarnings("unused")
263        @Nonnull
264        protected RequestPartitionId provideNonCompartmentMemberTypeResponse(IBaseResource theResource) {
265                return RequestPartitionId.fromPartitionId(myPartitionSettings.getDefaultPartitionId());
266        }
267}