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