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.Validate;
048import org.hl7.fhir.instance.model.api.IBase;
049import org.hl7.fhir.instance.model.api.IBaseBundle;
050import org.hl7.fhir.instance.model.api.IBaseReference;
051import org.hl7.fhir.instance.model.api.IBaseResource;
052import org.hl7.fhir.instance.model.api.IIdType;
053import org.hl7.fhir.r4.model.IdType;
054import org.springframework.beans.factory.annotation.Autowired;
055
056import java.util.ArrayList;
057import java.util.Collection;
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);
184                        if (compartmentSps.isEmpty()) {
185                                return provideNonCompartmentMemberTypeResponse(null);
186                        }
187                }
188
189                //noinspection EnumSwitchStatementWhichMissesCases
190                switch (theReadDetails.getRestOperationType()) {
191                        case READ:
192                        case VREAD:
193                                if ("Patient".equals(theReadDetails.getResourceType())) {
194                                        return provideCompartmentMemberInstanceResponse(
195                                                        theRequestDetails,
196                                                        theReadDetails.getReadResourceId().getIdPart());
197                                }
198                                break;
199                        case SEARCH_TYPE:
200                                SearchParameterMap params = theReadDetails.getSearchParams();
201                                assert params != null;
202                                if ("Patient".equals(theReadDetails.getResourceType())) {
203                                        List<String> idParts = getResourceIdList(params, "_id", "Patient", false);
204                                        if (idParts.size() == 1) {
205                                                return provideCompartmentMemberInstanceResponse(theRequestDetails, idParts.get(0));
206                                        } else {
207                                                return RequestPartitionId.allPartitions();
208                                        }
209                                } else {
210                                        for (RuntimeSearchParam nextCompartmentSp : compartmentSps) {
211                                                List<String> idParts = getResourceIdList(params, nextCompartmentSp.getName(), "Patient", true);
212                                                if (!idParts.isEmpty()) {
213                                                        return provideCompartmentMemberInstanceResponse(theRequestDetails, idParts.get(0));
214                                                }
215                                        }
216                                }
217
218                                break;
219                        case EXTENDED_OPERATION_SERVER:
220                                String extendedOp = theReadDetails.getExtendedOperationName();
221                                if (ProviderConstants.OPERATION_EXPORT.equals(extendedOp)
222                                                || ProviderConstants.OPERATION_EXPORT_POLL_STATUS.equals(extendedOp)) {
223                                        return provideNonPatientSpecificQueryResponse();
224                                }
225                                break;
226                        default:
227                                // nothing
228                }
229
230                if (isBlank(theReadDetails.getResourceType())) {
231                        return provideNonCompartmentMemberTypeResponse(null);
232                }
233
234                // If we couldn't identify a patient ID by the URL, let's try using the
235                // conditional target if we have one
236                if (theReadDetails.getConditionalTargetOrNull() != null) {
237                        return identifyForCreate(theReadDetails.getConditionalTargetOrNull(), theRequestDetails);
238                }
239
240                return provideNonPatientSpecificQueryResponse();
241        }
242
243        /**
244         * If we're about to process a FHIR transaction, we want to note the mappings between placeholder IDs
245         * and their resources and stuff them into a userdata map where we can access them later. We do this
246         * so that when we see a resource in the patient compartment (e.g. an Encounter) and it has a subject
247         * reference that's just a placeholder ID, we can look up the target of that and figure out which
248         * compartment that Encounter actually belongs to.
249         */
250        @Hook(Pointcut.STORAGE_TRANSACTION_PROCESSING)
251        public void transaction(RequestDetails theRequestDetails, IBaseBundle theBundle) {
252                FhirTerser terser = myFhirContext.newTerser();
253
254                /*
255                 * If we have a Patient in the transaction bundle which is being POST-ed as a normal
256                 * resource "create" (i.e., it will get a server-assigned ID), we'll proactively assign it an ID here.
257                 *
258                 * This is mostly a hack to get Synthea data working, but real clients could also be
259                 * following the same pattern.
260                 */
261                List<IBase> rawEntries = new ArrayList<>(terser.getValues(theBundle, "entry", IBase.class));
262                List<BundleEntryParts> parsedEntries = BundleUtil.toListOfEntries(myFhirContext, theBundle);
263                Validate.isTrue(rawEntries.size() == parsedEntries.size(), "Parsed and raw entries don't match");
264
265                Map<String, String> idSubstitutions = new HashMap<>();
266                for (int i = 0; i < rawEntries.size(); i++) {
267                        BundleEntryParts nextEntry = parsedEntries.get(i);
268                        if (nextEntry.getResource() != null
269                                        && myFhirContext.getResourceType(nextEntry.getResource()).equals("Patient")) {
270                                if (nextEntry.getMethod() == RequestTypeEnum.POST && isBlank(nextEntry.getConditionalUrl())) {
271                                        if (nextEntry.getFullUrl() != null && nextEntry.getFullUrl().startsWith("urn:uuid:")) {
272                                                String newId = UUID.randomUUID().toString();
273                                                nextEntry.getResource().setId(newId);
274                                                idSubstitutions.put(nextEntry.getFullUrl(), "Patient/" + newId);
275
276                                                IBase entry = rawEntries.get(i);
277                                                IBase request = terser.getValues(entry, "request").get(0);
278                                                terser.setElement(request, "ifNoneExist", null);
279                                                terser.setElement(request, "method", "PUT");
280                                                terser.setElement(request, "url", "Patient/" + newId);
281                                        }
282                                }
283                        }
284                }
285
286                if (!idSubstitutions.isEmpty()) {
287                        for (BundleEntryParts entry : parsedEntries) {
288                                IBaseResource resource = entry.getResource();
289                                if (resource != null) {
290                                        List<ResourceReferenceInfo> references = terser.getAllResourceReferences(resource);
291                                        for (ResourceReferenceInfo reference : references) {
292                                                String referenceString = reference
293                                                                .getResourceReference()
294                                                                .getReferenceElement()
295                                                                .getValue();
296                                                String substitution = idSubstitutions.get(referenceString);
297                                                if (substitution != null) {
298                                                        reference.getResourceReference().setReference(substitution);
299                                                }
300                                        }
301                                }
302                        }
303                }
304
305                List<BundleEntryParts> entries = BundleUtil.toListOfEntries(myFhirContext, theBundle);
306                Map<String, IBaseResource> placeholderToResource = new HashMap<>();
307                for (BundleEntryParts nextEntry : entries) {
308                        String fullUrl = nextEntry.getFullUrl();
309                        if (fullUrl != null && fullUrl.startsWith("urn:uuid:")) {
310                                if (nextEntry.getResource() != null) {
311                                        placeholderToResource.put(fullUrl, nextEntry.getResource());
312                                }
313                        }
314                }
315
316                if (theRequestDetails != null) {
317                        theRequestDetails.getUserData().put(PLACEHOLDER_TO_REFERENCE_KEY, placeholderToResource);
318                }
319        }
320
321        @SuppressWarnings("SameParameterValue")
322        private List<String> getResourceIdList(
323                        SearchParameterMap theParams, String theParamName, String theResourceType, boolean theExpectOnlyOneBool) {
324                List<List<IQueryParameterType>> idParamAndList = theParams.get(theParamName);
325                if (idParamAndList == null) {
326                        return Collections.emptyList();
327                }
328
329                List<String> idParts = new ArrayList<>();
330                idParamAndList.stream().flatMap(Collection::stream).forEach(idParam -> {
331                        if (isNotBlank(idParam.getQueryParameterQualifier())) {
332                                throw new MethodNotAllowedException(Msg.code(1322) + "The parameter " + theParamName
333                                                + idParam.getQueryParameterQualifier() + " is not supported in patient compartment mode");
334                        }
335                        if (idParam instanceof ReferenceParam) {
336                                String chain = ((ReferenceParam) idParam).getChain();
337                                if (chain != null) {
338                                        throw new MethodNotAllowedException(Msg.code(1323) + "The parameter " + theParamName + "." + chain
339                                                        + " is not supported in patient compartment mode");
340                                }
341                        }
342                        IdType id = new IdType(idParam.getValueAsQueryToken());
343                        if (!id.hasResourceType() || id.getResourceType().equals(theResourceType)) {
344                                idParts.add(id.getIdPart());
345                        }
346                });
347
348                if (theExpectOnlyOneBool && idParts.size() > 1) {
349                        throw new MethodNotAllowedException(Msg.code(1324) + "Multiple values for parameter " + theParamName
350                                        + " is not supported in patient compartment mode");
351                }
352
353                return idParts;
354        }
355
356        /**
357         * Return a partition or throw an error for FHIR operations that can not be used with this interceptor
358         */
359        protected RequestPartitionId provideNonPatientSpecificQueryResponse() {
360                return RequestPartitionId.allPartitions();
361        }
362
363        /**
364         * Generate the partition for a given patient resource ID. This method may be overridden in subclasses, but it
365         * may be easier to override {@link #providePartitionIdForPatientId(RequestDetails, String)} instead.
366         */
367        @Nonnull
368        protected RequestPartitionId provideCompartmentMemberInstanceResponse(
369                        RequestDetails theRequestDetails, String theResourceIdPart) {
370                int partitionId = providePartitionIdForPatientId(theRequestDetails, theResourceIdPart);
371                return RequestPartitionId.fromPartitionIdAndName(partitionId, theResourceIdPart);
372        }
373
374        /**
375         * Translates an ID (e.g. "ABC") into a compartment ID number.
376         * <p>
377         * The default implementation of this method returns:
378         * <code>Math.abs(theResourceIdPart.hashCode()) % 15000</code>.
379         * <p>
380         * This logic can be replaced with other logic of your choosing.
381         */
382        @SuppressWarnings("unused")
383        protected int providePartitionIdForPatientId(RequestDetails theRequestDetails, String theResourceIdPart) {
384                return Math.abs(theResourceIdPart.hashCode() % 15000);
385        }
386
387        /**
388         * Return a compartment ID (or throw an exception) when an attempt is made to search for a resource that is
389         * in the patient compartment, but without any search parameter identifying which compartment to search.
390         * <p>
391         * E.g. this method will be called for the search <code>Observation?code=foo</code> since the patient
392         * is not identified in the URL.
393         */
394        @Nonnull
395        protected RequestPartitionId throwNonCompartmentMemberInstanceFailureResponse(IBaseResource theResource) {
396                throw new MethodNotAllowedException(Msg.code(1326) + "Resource of type "
397                                + myFhirContext.getResourceType(theResource) + " has no values placing it in the Patient compartment");
398        }
399
400        /**
401         * Return a compartment ID (or throw an exception) when storing/reading resource types that
402         * are not in the patient compartment (e.g. ValueSet).
403         */
404        @SuppressWarnings("unused")
405        @Nonnull
406        protected RequestPartitionId provideNonCompartmentMemberTypeResponse(IBaseResource theResource) {
407                return myPartitionSettings.getDefaultRequestPartitionId();
408        }
409}