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