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