001/*-
002 * #%L
003 * HAPI FHIR - Master Data Management
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.mdm.interceptor;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.interceptor.api.Hook;
024import ca.uhn.fhir.interceptor.api.Pointcut;
025import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
026import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
027import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
028import ca.uhn.fhir.mdm.api.MdmConstants;
029import ca.uhn.fhir.mdm.log.Logs;
030import ca.uhn.fhir.mdm.svc.MdmSearchExpansionResults;
031import ca.uhn.fhir.mdm.svc.MdmSearchExpansionSvc;
032import ca.uhn.fhir.rest.api.server.IPreResourceShowDetails;
033import ca.uhn.fhir.rest.api.server.RequestDetails;
034import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId;
035import ca.uhn.fhir.rest.param.ReferenceParam;
036import ca.uhn.fhir.rest.server.util.ICachedSearchDetails;
037import ca.uhn.fhir.util.FhirTerser;
038import ca.uhn.fhir.util.ResourceReferenceInfo;
039import org.hl7.fhir.instance.model.api.IAnyResource;
040import org.hl7.fhir.instance.model.api.IBaseResource;
041import org.hl7.fhir.instance.model.api.IIdType;
042import org.slf4j.Logger;
043import org.springframework.beans.factory.annotation.Autowired;
044
045import java.util.HashSet;
046import java.util.List;
047import java.util.Optional;
048import java.util.Set;
049import java.util.stream.Collectors;
050
051/**
052 * <b>This class is experimental and subject to change. Use with caution.</b>
053 * <p>
054 * This interceptor provides an "MDM Virtualized" endpoint, meaning that
055 * searches are expanded to include MDM-linked resources (including any
056 * linked golden resource, and also including any other resources linked
057 * to that golden resource). Searches for non-MDM resources which have
058 * a reference to an MDM resource will have their reference parameter
059 * expanded to include the golden and linked resources.
060 * </p>
061 * <p>
062 * In addition, responses are cleaned up so that only the golden resource
063 * is included in responses, and references to non-golden resources
064 * are rewritten.
065 * </p>
066 * <p>
067 * This interceptor does not modify data that is being stored/written
068 * in any way, it only modifies data that is being returned by the
069 * server.
070 * </p>
071 *
072 * @since 8.0.0
073 */
074public class MdmReadVirtualizationInterceptor<P extends IResourcePersistentId<?>> {
075        private static final Logger ourMdmTroubleshootingLog = Logs.getMdmTroubleshootingLog();
076
077        private static final String CURRENTLY_PROCESSING_FLAG =
078                        MdmReadVirtualizationInterceptor.class.getName() + "_CURRENTLY_PROCESSING";
079
080        private static final MdmSearchExpansionSvc.IParamTester PARAM_TESTER_NO_RES_ID = (paramName, param) -> {
081                boolean hasChain = false;
082                if (param instanceof ReferenceParam) {
083                        hasChain = ((ReferenceParam) param).hasChain();
084                }
085                return !hasChain && !IAnyResource.SP_RES_ID.equals(paramName);
086        };
087
088        private static final MdmSearchExpansionSvc.IParamTester PARAM_TESTER_ALL = (paramName, param) -> {
089                boolean hasChain = false;
090                if (param instanceof ReferenceParam) {
091                        hasChain = ((ReferenceParam) param).hasChain();
092                }
093                return !hasChain;
094        };
095
096        @Autowired
097        private FhirContext myFhirContext;
098
099        @Autowired
100        private DaoRegistry myDaoRegistry;
101
102        @Autowired
103        private MdmSearchExpansionSvc myMdmSearchExpansionSvc;
104
105        @Hook(
106                        value = Pointcut.STORAGE_PRESEARCH_REGISTERED,
107                        order = MdmConstants.ORDER_PRESEARCH_REGISTERED_MDM_READ_VIRTUALIZATION_INTERCEPTOR)
108        public void preSearchRegistered(
109                        RequestDetails theRequestDetails,
110                        SearchParameterMap theSearchParameterMap,
111                        ICachedSearchDetails theSearchDetails) {
112                ourMdmTroubleshootingLog
113                                .atTrace()
114                                .setMessage("MDM virtualization original search: {}{}")
115                                .addArgument(theRequestDetails.getResourceName())
116                                .addArgument(() -> theSearchParameterMap.toNormalizedQueryString(myFhirContext))
117                                .log();
118
119                String resourceType = theSearchDetails.getResourceType();
120
121                if (theSearchParameterMap.hasIncludes() || theSearchParameterMap.hasRevIncludes()) {
122                        myMdmSearchExpansionSvc.expandSearchAndStoreInRequestDetails(
123                                        resourceType, theRequestDetails, theSearchParameterMap, PARAM_TESTER_ALL);
124                } else {
125                        // If we don't have any includes, it's not worth auto-expanding the _id parameter since we'll only end
126                        // up filtering out the extra resources afterward
127                        myMdmSearchExpansionSvc.expandSearchAndStoreInRequestDetails(
128                                        resourceType, theRequestDetails, theSearchParameterMap, PARAM_TESTER_NO_RES_ID);
129                }
130
131                ourMdmTroubleshootingLog
132                                .atDebug()
133                                .setMessage("MDM virtualization remapped search: {}{}")
134                                .addArgument(theRequestDetails.getResourceName())
135                                .addArgument(() -> theSearchParameterMap.toNormalizedQueryString(myFhirContext))
136                                .log();
137        }
138
139        @Hook(Pointcut.STORAGE_PRESHOW_RESOURCES)
140        public void preShowResources(RequestDetails theRequestDetails, IPreResourceShowDetails theDetails) {
141                MdmSearchExpansionResults expansionResults = MdmSearchExpansionSvc.getCachedExpansionResults(theRequestDetails);
142                if (expansionResults == null) {
143                        // This means the PRESEARCH hook didn't save anything, which probably means
144                        // no RequestDetails is available
145                        return;
146                }
147
148                if (theRequestDetails.getUserData().get(CURRENTLY_PROCESSING_FLAG) != null) {
149                        // Avoid recursive calls
150                        return;
151                }
152
153                /*
154                 * If a resource being returned is a resource that was mdm-expanded,
155                 * we'll replace that resource with the originally requested resource,
156                 * making sure to avoid adding duplicates to the results.
157                 */
158                Set<IIdType> resourcesInBundle = new HashSet<>();
159                for (int resourceIdx = 0; resourceIdx < theDetails.size(); resourceIdx++) {
160                        IBaseResource resource = theDetails.getResource(resourceIdx);
161                        IIdType id = resource.getIdElement().toUnqualifiedVersionless();
162                        Optional<IIdType> originalIdOpt = expansionResults.getOriginalIdForExpandedId(id);
163                        if (originalIdOpt.isPresent()) {
164                                IIdType originalId = originalIdOpt.get();
165                                if (resourcesInBundle.add(originalId)) {
166                                        IBaseResource originalResource = fetchResourceFromRepository(theRequestDetails, originalId);
167                                        theDetails.setResource(resourceIdx, originalResource);
168                                } else {
169                                        theDetails.setResource(resourceIdx, null);
170                                }
171                        } else {
172                                if (!resourcesInBundle.add(id)) {
173                                        theDetails.setResource(resourceIdx, null);
174                                }
175                        }
176                }
177
178                FhirTerser terser = myFhirContext.newTerser();
179                for (int resourceIdx = 0; resourceIdx < theDetails.size(); resourceIdx++) {
180                        IBaseResource resource = theDetails.getResource(resourceIdx);
181                        if (resource != null) {
182
183                                // Extract all the references in the resources we're returning
184                                // in case we need to remap them to golden equivalents
185                                List<ResourceReferenceInfo> referenceInfos = terser.getAllResourceReferences(resource);
186                                for (ResourceReferenceInfo referenceInfo : referenceInfos) {
187                                        IIdType referenceId = referenceInfo
188                                                        .getResourceReference()
189                                                        .getReferenceElement()
190                                                        .toUnqualifiedVersionless();
191                                        if (referenceId.hasResourceType()
192                                                        && referenceId.hasIdPart()
193                                                        && !referenceId.isLocal()
194                                                        && !referenceId.isUuid()) {
195                                                Optional<IIdType> nonExpandedId = expansionResults.getOriginalIdForExpandedId(referenceId);
196                                                if (nonExpandedId != null && nonExpandedId.isPresent()) {
197                                                        ourMdmTroubleshootingLog.debug(
198                                                                        "MDM virtualization is replacing reference at {} value {} with {}",
199                                                                        referenceInfo.getName(),
200                                                                        referenceInfo.getResourceReference().getReferenceElement(),
201                                                                        nonExpandedId.get().getValue());
202                                                        referenceInfo
203                                                                        .getResourceReference()
204                                                                        .setReference(nonExpandedId.get().getValue());
205                                                }
206                                        }
207                                }
208                        }
209                }
210
211                ourMdmTroubleshootingLog
212                                .atTrace()
213                                .setMessage("Returning resources: {}")
214                                .addArgument(() -> theDetails.getAllResources().stream()
215                                                .map(t -> t.getIdElement().toUnqualifiedVersionless().getValue())
216                                                .sorted()
217                                                .collect(Collectors.toList()))
218                                .log();
219        }
220
221        private IBaseResource fetchResourceFromRepository(RequestDetails theRequestDetails, IIdType originalId) {
222                IFhirResourceDao<?> dao = myDaoRegistry.getResourceDao(originalId.getResourceType());
223                theRequestDetails.getUserData().put(CURRENTLY_PROCESSING_FLAG, Boolean.TRUE);
224                IBaseResource originalResource;
225                try {
226                        originalResource = dao.read(originalId, theRequestDetails);
227                } finally {
228                        theRequestDetails.getUserData().remove(CURRENTLY_PROCESSING_FLAG);
229                }
230                return originalResource;
231        }
232}