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.auth.CompartmentSearchParameterModifications;
030import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails;
031import ca.uhn.fhir.interceptor.model.RequestPartitionId;
032import ca.uhn.fhir.jpa.model.config.PartitionSettings;
033import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
034import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor;
035import ca.uhn.fhir.jpa.util.ResourceCompartmentUtil;
036import ca.uhn.fhir.model.api.IQueryParameterType;
037import ca.uhn.fhir.rest.api.RequestTypeEnum;
038import ca.uhn.fhir.rest.api.server.RequestDetails;
039import ca.uhn.fhir.rest.param.ReferenceParam;
040import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException;
041import ca.uhn.fhir.rest.server.provider.ProviderConstants;
042import ca.uhn.fhir.util.BundleUtil;
043import ca.uhn.fhir.util.FhirTerser;
044import ca.uhn.fhir.util.ResourceReferenceInfo;
045import ca.uhn.fhir.util.bundle.BundleEntryParts;
046import jakarta.annotation.Nonnull;
047import org.apache.commons.lang3.Strings;
048import org.apache.commons.lang3.Validate;
049import org.hl7.fhir.instance.model.api.IBase;
050import org.hl7.fhir.instance.model.api.IBaseBundle;
051import org.hl7.fhir.instance.model.api.IBaseReference;
052import org.hl7.fhir.instance.model.api.IBaseResource;
053import org.hl7.fhir.instance.model.api.IIdType;
054import org.hl7.fhir.r4.model.IdType;
055import org.springframework.beans.factory.annotation.Autowired;
056
057import java.util.ArrayList;
058import java.util.Collections;
059import java.util.HashMap;
060import java.util.List;
061import java.util.Map;
062import java.util.Objects;
063import java.util.Optional;
064import java.util.UUID;
065
066import static ca.uhn.fhir.interceptor.model.RequestPartitionId.getPartitionIfAssigned;
067import static org.apache.commons.lang3.StringUtils.isBlank;
068import static org.apache.commons.lang3.StringUtils.isEmpty;
069import static org.apache.commons.lang3.StringUtils.isNotBlank;
070
071/**
072 * This interceptor allows JPA servers to be partitioned by Patient ID. It selects the compartment for read/create operations
073 * based on the patient ID associated with the resource (and uses a default partition ID for any resources
074 * not in the patient compartment).
075 * This works better with IdStrategyEnum.UUID and CrossPartitionReferenceMode.ALLOWED_UNQUALIFIED.
076 */
077@Interceptor
078public class PatientIdPartitionInterceptor {
079
080        public static final String PLACEHOLDER_TO_REFERENCE_KEY =
081                        PatientIdPartitionInterceptor.class.getName() + "_placeholderToResource";
082
083        @Autowired
084        private FhirContext myFhirContext;
085
086        @Autowired
087        private ISearchParamExtractor mySearchParamExtractor;
088
089        @Autowired
090        private PartitionSettings myPartitionSettings;
091
092        /**
093         * Constructor
094         */
095        public PatientIdPartitionInterceptor(
096                        FhirContext theFhirContext,
097                        ISearchParamExtractor theSearchParamExtractor,
098                        PartitionSettings thePartitionSettings) {
099                myFhirContext = theFhirContext;
100                mySearchParamExtractor = theSearchParamExtractor;
101                myPartitionSettings = thePartitionSettings;
102        }
103
104        @Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE)
105        public RequestPartitionId identifyForCreate(IBaseResource theResource, RequestDetails theRequestDetails) {
106                RuntimeResourceDefinition resourceDef = myFhirContext.getResourceDefinition(theResource);
107                List<RuntimeSearchParam> compartmentSps =
108                                ResourceCompartmentUtil.getPatientCompartmentSearchParams(resourceDef);
109
110                if (compartmentSps.isEmpty() || resourceDef.getName().equals("Group")) {
111                        return provideNonCompartmentMemberTypeResponse(theResource);
112                }
113
114                if (resourceDef.getName().equals("Patient")) {
115                        IIdType idElement = theResource.getIdElement();
116                        if (idElement.getIdPart() == null || idElement.isUuid()) {
117                                throw new MethodNotAllowedException(
118                                                Msg.code(1321)
119                                                                + "Patient resource IDs must be client-assigned in patient compartment mode, or server id strategy must be UUID");
120                        }
121                        return provideCompartmentMemberInstanceResponse(theRequestDetails, idElement.getIdPart());
122                } else {
123                        Optional<String> oCompartmentIdentity = ResourceCompartmentUtil.getResourceCompartment(
124                                        "Patient", theResource, compartmentSps, mySearchParamExtractor);
125
126                        if (oCompartmentIdentity.isPresent()) {
127                                return provideCompartmentMemberInstanceResponse(theRequestDetails, oCompartmentIdentity.get());
128                        } else {
129                                return getPartitionViaPartiallyProcessedReference(theRequestDetails, theResource)
130                                                // or give up and fail
131                                                .orElseGet(() -> throwNonCompartmentMemberInstanceFailureResponse(theResource));
132                        }
133                }
134        }
135
136        /**
137         * HACK: enable synthea bundles to sneak through with a server-assigned UUID.
138         * If we don't have a simple id for a compartment owner, maybe we're in a bundle during processing
139         * and a reference points to the Patient which has already been processed and assigned a partition.
140         */
141        @SuppressWarnings("unchecked")
142        @Nonnull
143        private Optional<RequestPartitionId> getPartitionViaPartiallyProcessedReference(
144                        RequestDetails theRequestDetails, IBaseResource theResource) {
145                Map<String, IBaseResource> placeholderToReference = null;
146                if (theRequestDetails != null) {
147                        placeholderToReference =
148                                        (Map<String, IBaseResource>) theRequestDetails.getUserData().get(PLACEHOLDER_TO_REFERENCE_KEY);
149                }
150                if (placeholderToReference == null) {
151                        placeholderToReference = Map.of();
152                }
153
154                List<IBaseReference> references = myFhirContext
155                                .newTerser()
156                                .getCompartmentReferencesForResource(
157                                                "Patient", theResource, new CompartmentSearchParameterModifications())
158                                .toList();
159                for (IBaseReference reference : references) {
160                        String referenceString = reference.getReferenceElement().getValue();
161                        IBaseResource target = placeholderToReference.get(referenceString);
162                        if (target != null && Objects.equals(myFhirContext.getResourceType(target), "Patient")) {
163                                if ("Patient".equals(target.getIdElement().getResourceType())) {
164                                        if (!target.getIdElement().isUuid() && target.getIdElement().hasIdPart()) {
165                                                return Optional.of(provideCompartmentMemberInstanceResponse(
166                                                                theRequestDetails, target.getIdElement().getIdPart()));
167                                        }
168                                }
169                                return getPartitionIfAssigned(target);
170                        }
171                }
172
173                return Optional.empty();
174        }
175
176        @Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_READ)
177        public RequestPartitionId identifyForRead(
178                        @Nonnull ReadPartitionIdRequestDetails theReadDetails, RequestDetails theRequestDetails) {
179                List<RuntimeSearchParam> compartmentSps = Collections.emptyList();
180                if (!isEmpty(theReadDetails.getResourceType())) {
181                        RuntimeResourceDefinition resourceDef =
182                                        myFhirContext.getResourceDefinition(theReadDetails.getResourceType());
183                        compartmentSps = ResourceCompartmentUtil.getPatientCompartmentSearchParams(resourceDef, true);
184                        if (compartmentSps.isEmpty()) {
185                                return provideNonCompartmentMemberTypeResponse(null);
186                        }
187                }
188
189                //noinspection EnumSwitchStatementWhichMissesCases
190                switch (theReadDetails.getRestOperationType()) {
191                        case DELETE:
192                        case PATCH:
193                        case READ:
194                        case VREAD:
195                        case SEARCH_TYPE:
196                                if (theReadDetails.getSearchParams() != null) {
197                                        SearchParameterMap params = theReadDetails.getSearchParams();
198                                        if ("Patient".equals(theReadDetails.getResourceType())) {
199                                                List<String> idParts = getResourceIdList(params, "_id", false);
200                                                if (idParts.size() == 1) {
201                                                        return provideCompartmentMemberInstanceResponse(theRequestDetails, idParts.get(0));
202                                                } else {
203                                                        return RequestPartitionId.allPartitions();
204                                                }
205                                        } else {
206                                                for (RuntimeSearchParam nextCompartmentSp : compartmentSps) {
207                                                        List<String> idParts = getResourceIdList(params, nextCompartmentSp.getName(), true);
208                                                        if (!idParts.isEmpty()) {
209                                                                return provideCompartmentMemberInstanceResponse(theRequestDetails, idParts.get(0));
210                                                        }
211                                                }
212                                        }
213                                } else if (theReadDetails.getReadResourceId() != null) {
214                                        if ("Patient".equals(theReadDetails.getResourceType())) {
215                                                return provideCompartmentMemberInstanceResponse(
216                                                                theRequestDetails,
217                                                                theReadDetails.getReadResourceId().getIdPart());
218                                        }
219                                }
220                                break;
221                        case EXTENDED_OPERATION_SERVER:
222                                String extendedOp = theReadDetails.getExtendedOperationName();
223                                if (ProviderConstants.OPERATION_EXPORT.equals(extendedOp)
224                                                || ProviderConstants.OPERATION_EXPORT_POLL_STATUS.equals(extendedOp)) {
225                                        return provideNonPatientSpecificQueryResponse();
226                                }
227                                break;
228                        default:
229                                // nothing
230                }
231
232                if (isBlank(theReadDetails.getResourceType())) {
233                        return provideNonCompartmentMemberTypeResponse(null);
234                }
235
236                // If we couldn't identify a patient ID by the URL, let's try using the
237                // conditional target if we have one
238                if (theReadDetails.getConditionalTargetOrNull() != null) {
239                        return identifyForCreate(theReadDetails.getConditionalTargetOrNull(), theRequestDetails);
240                }
241
242                return provideNonPatientSpecificQueryResponse();
243        }
244
245        /**
246         * If we're about to process a FHIR transaction, we want to note the mappings between placeholder IDs
247         * and their resources and stuff them into a userdata map where we can access them later. We do this
248         * so that when we see a resource in the patient compartment (e.g. an Encounter) and it has a subject
249         * reference that's just a placeholder ID, we can look up the target of that and figure out which
250         * compartment that Encounter actually belongs to.
251         */
252        @Hook(Pointcut.STORAGE_TRANSACTION_PROCESSING)
253        public void transaction(RequestDetails theRequestDetails, IBaseBundle theBundle) {
254                FhirTerser terser = myFhirContext.newTerser();
255
256                /*
257                 * If we have a Patient in the transaction bundle which is being POST-ed as a normal
258                 * resource "create" (i.e., it will get a server-assigned ID), we'll proactively assign it an ID here.
259                 *
260                 * This is mostly a hack to get Synthea data working, but real clients could also be
261                 * following the same pattern.
262                 */
263                List<IBase> rawEntries = new ArrayList<>(terser.getValues(theBundle, "entry", IBase.class));
264                List<BundleEntryParts> parsedEntries = BundleUtil.toListOfEntries(myFhirContext, theBundle);
265                Validate.isTrue(rawEntries.size() == parsedEntries.size(), "Parsed and raw entries don't match");
266
267                Map<String, String> idSubstitutions = new HashMap<>();
268                for (int i = 0; i < rawEntries.size(); i++) {
269                        BundleEntryParts nextEntry = parsedEntries.get(i);
270                        if (nextEntry.getResource() != null
271                                        && myFhirContext.getResourceType(nextEntry.getResource()).equals("Patient")) {
272                                if (nextEntry.getMethod() == RequestTypeEnum.POST && isBlank(nextEntry.getConditionalUrl())) {
273                                        if (nextEntry.getFullUrl() != null && nextEntry.getFullUrl().startsWith("urn:uuid:")) {
274                                                String newId = UUID.randomUUID().toString();
275                                                nextEntry.getResource().setId(newId);
276                                                idSubstitutions.put(nextEntry.getFullUrl(), "Patient/" + newId);
277
278                                                IBase entry = rawEntries.get(i);
279                                                IBase request = terser.getValues(entry, "request").get(0);
280                                                terser.setElement(request, "ifNoneExist", null);
281                                                terser.setElement(request, "method", "PUT");
282                                                terser.setElement(request, "url", "Patient/" + newId);
283                                        }
284                                }
285                        }
286                }
287
288                if (!idSubstitutions.isEmpty()) {
289                        for (BundleEntryParts entry : parsedEntries) {
290                                IBaseResource resource = entry.getResource();
291                                if (resource != null) {
292                                        List<ResourceReferenceInfo> references = terser.getAllResourceReferences(resource);
293                                        for (ResourceReferenceInfo reference : references) {
294                                                String referenceString = reference
295                                                                .getResourceReference()
296                                                                .getReferenceElement()
297                                                                .getValue();
298                                                String substitution = idSubstitutions.get(referenceString);
299                                                if (substitution != null) {
300                                                        reference.getResourceReference().setReference(substitution);
301                                                }
302                                        }
303                                }
304                        }
305                }
306
307                List<BundleEntryParts> entries = BundleUtil.toListOfEntries(myFhirContext, theBundle);
308                Map<String, IBaseResource> placeholderToResource = new HashMap<>();
309                for (BundleEntryParts nextEntry : entries) {
310                        String fullUrl = nextEntry.getFullUrl();
311                        if (fullUrl != null && fullUrl.startsWith("urn:uuid:")) {
312                                if (nextEntry.getResource() != null) {
313                                        placeholderToResource.put(fullUrl, nextEntry.getResource());
314                                }
315                        }
316                }
317
318                if (theRequestDetails != null) {
319                        theRequestDetails.getUserData().put(PLACEHOLDER_TO_REFERENCE_KEY, placeholderToResource);
320                }
321        }
322
323        @SuppressWarnings("SameParameterValue")
324        private List<String> getResourceIdList(
325                        SearchParameterMap theParams, String theParamName, boolean theExpectOnlyOneBool) {
326                List<List<IQueryParameterType>> idParamAndList = theParams.get(theParamName);
327                if (idParamAndList == null) {
328                        return Collections.emptyList();
329                }
330
331                List<String> idParts = new ArrayList<>();
332                for (List<IQueryParameterType> iQueryParameterTypes : idParamAndList) {
333                        for (IQueryParameterType idParam : iQueryParameterTypes) {
334                                if (isNotBlank(idParam.getQueryParameterQualifier())) {
335                                        throw new MethodNotAllowedException(Msg.code(1322) + "The parameter " + theParamName
336                                                        + idParam.getQueryParameterQualifier() + " is not supported in patient compartment mode");
337                                }
338                                if (idParam instanceof ReferenceParam) {
339                                        String chain = ((ReferenceParam) idParam).getChain();
340                                        if (chain != null) {
341                                                throw new MethodNotAllowedException(Msg.code(1323) + "The parameter " + theParamName + "."
342                                                                + chain + " is not supported in patient compartment mode");
343                                        }
344                                }
345
346                                String valueAsQueryToken = idParam.getValueAsQueryToken();
347                                if (Strings.CS.startsWith(valueAsQueryToken, "Patient/")) {
348                                        IdType id = new IdType(valueAsQueryToken);
349                                        if (id.getResourceType().equals("Patient")) {
350                                                idParts.add(id.getIdPart());
351                                        }
352                                } else if (valueAsQueryToken.indexOf('/') == -1) {
353                                        IdType id = new IdType(valueAsQueryToken);
354                                        if (id.isIdPartValid()) {
355                                                idParts.add(valueAsQueryToken);
356                                        }
357                                }
358                        }
359                }
360
361                if (theExpectOnlyOneBool && idParts.size() > 1) {
362                        throw new MethodNotAllowedException(Msg.code(1324) + "Multiple values for parameter " + theParamName
363                                        + " is not supported in patient compartment mode");
364                }
365
366                return idParts;
367        }
368
369        /**
370         * Return a partition or throw an error for FHIR operations that can not be used with this interceptor
371         */
372        protected RequestPartitionId provideNonPatientSpecificQueryResponse() {
373                return RequestPartitionId.allPartitions();
374        }
375
376        /**
377         * Generate the partition for a given patient resource ID. This method may be overridden in subclasses, but it
378         * may be easier to override {@link #providePartitionIdForPatientId(RequestDetails, String)} instead.
379         */
380        @Nonnull
381        protected RequestPartitionId provideCompartmentMemberInstanceResponse(
382                        RequestDetails theRequestDetails, String theResourceIdPart) {
383                int partitionId = providePartitionIdForPatientId(theRequestDetails, theResourceIdPart);
384                return RequestPartitionId.fromPartitionIdAndName(partitionId, theResourceIdPart);
385        }
386
387        /**
388         * Translates an ID (e.g. "ABC") into a compartment ID number.
389         * <p>
390         * The default implementation of this method returns:
391         * <code>Math.abs(theResourceIdPart.hashCode()) % 15000</code>.
392         * <p>
393         * This logic can be replaced with other logic of your choosing.
394         */
395        @SuppressWarnings("unused")
396        protected int providePartitionIdForPatientId(RequestDetails theRequestDetails, String theResourceIdPart) {
397                return Math.abs(theResourceIdPart.hashCode() % 15000);
398        }
399
400        /**
401         * Return a compartment ID (or throw an exception) when an attempt is made to search for a resource that is
402         * in the patient compartment, but without any search parameter identifying which compartment to search.
403         * <p>
404         * E.g. this method will be called for the search <code>Observation?code=foo</code> since the patient
405         * is not identified in the URL.
406         */
407        @Nonnull
408        protected RequestPartitionId throwNonCompartmentMemberInstanceFailureResponse(IBaseResource theResource) {
409                throw new MethodNotAllowedException(Msg.code(1326) + "Resource of type "
410                                + myFhirContext.getResourceType(theResource) + " has no values placing it in the Patient compartment");
411        }
412
413        /**
414         * Return a compartment ID (or throw an exception) when storing/reading resource types that
415         * are not in the patient compartment (e.g. ValueSet).
416         */
417        @SuppressWarnings("unused")
418        @Nonnull
419        protected RequestPartitionId provideNonCompartmentMemberTypeResponse(IBaseResource theResource) {
420                return myPartitionSettings.getDefaultRequestPartitionId();
421        }
422}