001/*-
002 * #%L
003 * HAPI FHIR - Master Data Management
004 * %%
005 * Copyright (C) 2014 - 2024 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.provider;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.i18n.Msg;
024import ca.uhn.fhir.mdm.api.IMdmControllerSvc;
025import ca.uhn.fhir.mdm.api.MdmMatchResultEnum;
026import ca.uhn.fhir.mdm.api.paging.MdmPageLinkBuilder;
027import ca.uhn.fhir.mdm.api.paging.MdmPageLinkTuple;
028import ca.uhn.fhir.mdm.api.paging.MdmPageRequest;
029import ca.uhn.fhir.mdm.api.params.MdmHistorySearchParameters;
030import ca.uhn.fhir.mdm.model.MdmTransactionContext;
031import ca.uhn.fhir.mdm.model.mdmevents.MdmLinkJson;
032import ca.uhn.fhir.mdm.model.mdmevents.MdmLinkWithRevisionJson;
033import ca.uhn.fhir.rest.api.server.RequestDetails;
034import ca.uhn.fhir.rest.server.TransactionLogMessages;
035import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
036import ca.uhn.fhir.rest.server.provider.ProviderConstants;
037import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
038import ca.uhn.fhir.util.ParametersUtil;
039import jakarta.annotation.Nonnull;
040import jakarta.annotation.Nullable;
041import org.hl7.fhir.instance.model.api.IBase;
042import org.hl7.fhir.instance.model.api.IBaseParameters;
043import org.hl7.fhir.instance.model.api.IPrimitiveType;
044import org.springframework.data.domain.Page;
045
046import java.util.Arrays;
047import java.util.Collection;
048import java.util.Collections;
049import java.util.Comparator;
050import java.util.List;
051import java.util.Map;
052import java.util.Objects;
053import java.util.Optional;
054import java.util.stream.Collectors;
055
056public abstract class BaseMdmProvider {
057
058        protected final FhirContext myFhirContext;
059        protected final IMdmControllerSvc myMdmControllerSvc;
060
061        public BaseMdmProvider(FhirContext theFhirContext, IMdmControllerSvc theMdmControllerSvc) {
062                myFhirContext = theFhirContext;
063                myMdmControllerSvc = theMdmControllerSvc;
064        }
065
066        protected void validateMergeParameters(
067                        IPrimitiveType<String> theFromGoldenResourceId, IPrimitiveType<String> theToGoldenResourceId) {
068                validateNotNull(ProviderConstants.MDM_MERGE_GR_FROM_GOLDEN_RESOURCE_ID, theFromGoldenResourceId);
069                validateNotNull(ProviderConstants.MDM_MERGE_GR_TO_GOLDEN_RESOURCE_ID, theToGoldenResourceId);
070                if (theFromGoldenResourceId.getValue().equals(theToGoldenResourceId.getValue())) {
071                        throw new InvalidRequestException(
072                                        Msg.code(1493) + "fromGoldenResourceId must be different from toGoldenResourceId");
073                }
074        }
075
076        private void validateNotNull(String theName, IPrimitiveType<String> theString) {
077                if (theString == null || theString.getValue() == null) {
078                        throw new InvalidRequestException(Msg.code(1494) + theName + " cannot be null");
079                }
080        }
081
082        protected void validateMdmLinkHistoryParameters(
083                        List<IPrimitiveType<String>> theGoldenResourceIds, List<IPrimitiveType<String>> theSourceIds) {
084                validateBothCannotBeNullOrEmpty(
085                                ProviderConstants.MDM_QUERY_LINKS_GOLDEN_RESOURCE_ID,
086                                theGoldenResourceIds,
087                                ProviderConstants.MDM_QUERY_LINKS_RESOURCE_ID,
088                                theSourceIds);
089        }
090
091        private void validateBothCannotBeNullOrEmpty(
092                        String theFirstName,
093                        List<IPrimitiveType<String>> theFirstList,
094                        String theSecondName,
095                        List<IPrimitiveType<String>> theSecondList) {
096                if ((theFirstList == null || theFirstList.isEmpty()) && (theSecondList == null || theSecondList.isEmpty())) {
097                        throw new InvalidRequestException(Msg.code(2292) + "Please include either [" + theFirstName + "]s, ["
098                                        + theSecondName + "]s, or both in your search inputs.");
099                }
100        }
101
102        protected void validateUpdateLinkParameters(
103                        IPrimitiveType<String> theGoldenResourceId,
104                        IPrimitiveType<String> theResourceId,
105                        IPrimitiveType<String> theMatchResult) {
106                validateNotNull(ProviderConstants.MDM_UPDATE_LINK_GOLDEN_RESOURCE_ID, theGoldenResourceId);
107                validateNotNull(ProviderConstants.MDM_UPDATE_LINK_RESOURCE_ID, theResourceId);
108                validateNotNull(ProviderConstants.MDM_UPDATE_LINK_MATCH_RESULT, theMatchResult);
109                MdmMatchResultEnum matchResult = MdmMatchResultEnum.valueOf(theMatchResult.getValue());
110                switch (matchResult) {
111                        case NO_MATCH:
112                        case MATCH:
113                                break;
114                        default:
115                                throw new InvalidRequestException(Msg.code(1495) + ProviderConstants.MDM_UPDATE_LINK + " illegal "
116                                                + ProviderConstants.MDM_UPDATE_LINK_MATCH_RESULT + " value '" + matchResult + "'.  Must be "
117                                                + MdmMatchResultEnum.NO_MATCH + " or " + MdmMatchResultEnum.MATCH);
118                }
119        }
120
121        protected void validateNotDuplicateParameters(
122                        IPrimitiveType<String> theGoldenResourceId, IPrimitiveType<String> theResourceId) {
123                validateNotNull(ProviderConstants.MDM_UPDATE_LINK_GOLDEN_RESOURCE_ID, theGoldenResourceId);
124                validateNotNull(ProviderConstants.MDM_UPDATE_LINK_RESOURCE_ID, theResourceId);
125        }
126
127        protected void validateCreateLinkParameters(
128                        IPrimitiveType<String> theGoldenResourceId,
129                        IPrimitiveType<String> theResourceId,
130                        @Nullable IPrimitiveType<String> theMatchResult) {
131                validateNotNull(ProviderConstants.MDM_CREATE_LINK_GOLDEN_RESOURCE_ID, theGoldenResourceId);
132                validateNotNull(ProviderConstants.MDM_CREATE_LINK_RESOURCE_ID, theResourceId);
133                if (theMatchResult != null) {
134                        MdmMatchResultEnum matchResult = MdmMatchResultEnum.valueOf(theMatchResult.getValue());
135                        switch (matchResult) {
136                                case NO_MATCH:
137                                case POSSIBLE_MATCH:
138                                case MATCH:
139                                        break;
140                                default:
141                                        throw new InvalidRequestException(Msg.code(1496) + ProviderConstants.MDM_CREATE_LINK + " illegal "
142                                                        + ProviderConstants.MDM_CREATE_LINK_MATCH_RESULT + " value '" + matchResult + "'.  Must be "
143                                                        + MdmMatchResultEnum.NO_MATCH + ", " + MdmMatchResultEnum.MATCH + " or "
144                                                        + MdmMatchResultEnum.POSSIBLE_MATCH);
145                        }
146                }
147        }
148
149        protected MdmTransactionContext createMdmContext(
150                        RequestDetails theRequestDetails,
151                        MdmTransactionContext.OperationType theOperationType,
152                        String theResourceType) {
153                TransactionLogMessages transactionLogMessages =
154                                TransactionLogMessages.createFromTransactionGuid(theRequestDetails.getTransactionGuid());
155                MdmTransactionContext mdmTransactionContext =
156                                new MdmTransactionContext(transactionLogMessages, theOperationType);
157                mdmTransactionContext.setResourceType(theResourceType);
158                return mdmTransactionContext;
159        }
160
161        @Nonnull
162        protected List<String> convertToStringsIncludingCommaDelimitedIfNotNull(
163                        List<IPrimitiveType<String>> thePrimitiveTypeStrings) {
164                if (thePrimitiveTypeStrings == null) {
165                        return Collections.emptyList();
166                }
167
168                return thePrimitiveTypeStrings.stream()
169                                .map(this::extractStringOrNull)
170                                .filter(Objects::nonNull)
171                                .map(input -> Arrays.asList(input.split(",")))
172                                .flatMap(Collection::stream)
173                                .collect(Collectors.toUnmodifiableList());
174        }
175
176        protected String extractStringOrNull(IPrimitiveType<String> theString) {
177                if (theString == null) {
178                        return null;
179                }
180                return theString.getValue();
181        }
182
183        protected IBaseParameters parametersFromMdmLinks(
184                        Page<MdmLinkJson> theMdmLinkStream,
185                        boolean theIncludeResultAndSource,
186                        ServletRequestDetails theServletRequestDetails,
187                        MdmPageRequest thePageRequest) {
188                IBaseParameters retval = ParametersUtil.newInstance(myFhirContext);
189                addPagingParameters(retval, theMdmLinkStream, theServletRequestDetails, thePageRequest);
190
191                long numDuplicates = theMdmLinkStream.getTotalElements();
192                ParametersUtil.addParameterToParametersLong(myFhirContext, retval, "total", numDuplicates);
193
194                theMdmLinkStream.getContent().forEach(mdmLink -> {
195                        IBase resultPart = ParametersUtil.addParameterToParameters(myFhirContext, retval, "link");
196                        ParametersUtil.addPartString(myFhirContext, resultPart, "goldenResourceId", mdmLink.getGoldenResourceId());
197                        ParametersUtil.addPartString(myFhirContext, resultPart, "sourceResourceId", mdmLink.getSourceId());
198
199                        if (theIncludeResultAndSource) {
200                                ParametersUtil.addPartString(
201                                                myFhirContext,
202                                                resultPart,
203                                                "matchResult",
204                                                mdmLink.getMatchResult().name());
205                                ParametersUtil.addPartString(
206                                                myFhirContext,
207                                                resultPart,
208                                                "linkSource",
209                                                mdmLink.getLinkSource().name());
210                                ParametersUtil.addPartBoolean(myFhirContext, resultPart, "eidMatch", mdmLink.getEidMatch());
211                                ParametersUtil.addPartBoolean(
212                                                myFhirContext, resultPart, "hadToCreateNewResource", mdmLink.getLinkCreatedNewResource());
213                                ParametersUtil.addPartDecimal(myFhirContext, resultPart, "score", mdmLink.getScore());
214                                ParametersUtil.addPartDecimal(myFhirContext, resultPart, "linkCreated", (double)
215                                                mdmLink.getCreated().getTime());
216                                ParametersUtil.addPartDecimal(myFhirContext, resultPart, "linkUpdated", (double)
217                                                mdmLink.getUpdated().getTime());
218                        }
219                });
220                return retval;
221        }
222
223        protected void parametersFromMdmLinkRevisions(
224                        IBaseParameters theRetVal,
225                        List<MdmLinkWithRevisionJson> theMdmLinkRevisions,
226                        ServletRequestDetails theRequestDetails) {
227                if (theMdmLinkRevisions.isEmpty()) {
228                        final IBase resultPart = ParametersUtil.addParameterToParameters(
229                                        myFhirContext, theRetVal, "historical links not found for query parameters");
230
231                        ParametersUtil.addPartString(
232                                        myFhirContext, resultPart, "theResults", "historical links not found for query parameters");
233                }
234
235                theMdmLinkRevisions.forEach(mdmLinkRevision -> parametersFromMdmLinkRevision(
236                                theRetVal,
237                                mdmLinkRevision,
238                                findInitialMatchResult(theMdmLinkRevisions, mdmLinkRevision, theRequestDetails)));
239        }
240
241        private MdmMatchResultEnum findInitialMatchResult(
242                        List<MdmLinkWithRevisionJson> theRevisionList,
243                        MdmLinkWithRevisionJson theToMatch,
244                        ServletRequestDetails theRequestDetails) {
245                String sourceId = theToMatch.getMdmLink().getSourceId();
246                String goldenId = theToMatch.getMdmLink().getGoldenResourceId();
247
248                // In the REDIRECT case, both the goldenResourceId and sourceResourceId fields are actually both
249                // golden resources. Because of this, based on our history query, it's possible not all links
250                // involving that golden resource will show up in the results (eg. query for goldenResourceId = GR/1
251                // but sourceResourceId = GR/1 in the link history). Hence, we should re-query to find the initial
252                // match result.
253                if (theToMatch.getMdmLink().getMatchResult() == MdmMatchResultEnum.REDIRECT) {
254                        MdmHistorySearchParameters params = new MdmHistorySearchParameters()
255                                        .setSourceIds(List.of(sourceId, goldenId))
256                                        .setGoldenResourceIds(List.of(sourceId, goldenId));
257
258                        List<MdmLinkWithRevisionJson> result = myMdmControllerSvc.queryLinkHistory(params, theRequestDetails);
259                        // If there is a POSSIBLE_DUPLICATE, a user merged two resources with *pre-existing* POSSIBLE_DUPLICATE link
260                        // so the initial match result is POSSIBLE_DUPLICATE
261                        // If no POSSIBLE_DUPLICATE, a user merged two *unlinked* GRs, so the initial match result is REDIRECT
262                        return containsPossibleDuplicate(result)
263                                        ? MdmMatchResultEnum.POSSIBLE_DUPLICATE
264                                        : MdmMatchResultEnum.REDIRECT;
265                }
266
267                // Get first match result with given source and golden ID
268                Optional<MdmLinkWithRevisionJson> theEarliestRevision = theRevisionList.stream()
269                                .filter(revision -> revision.getMdmLink().getSourceId().equals(sourceId))
270                                .filter(revision -> revision.getMdmLink().getGoldenResourceId().equals(goldenId))
271                                .min(Comparator.comparing(MdmLinkWithRevisionJson::getRevisionNumber));
272
273                return theEarliestRevision.isPresent()
274                                ? theEarliestRevision.get().getMdmLink().getMatchResult()
275                                : theToMatch.getMdmLink().getMatchResult();
276        }
277
278        private static boolean containsPossibleDuplicate(List<MdmLinkWithRevisionJson> result) {
279                return result.stream().anyMatch(t -> t.getMdmLink().getMatchResult() == MdmMatchResultEnum.POSSIBLE_DUPLICATE);
280        }
281
282        private void parametersFromMdmLinkRevision(
283                        IBaseParameters theRetVal,
284                        MdmLinkWithRevisionJson theMdmLinkRevision,
285                        MdmMatchResultEnum theInitialAutoResult) {
286                final IBase resultPart = ParametersUtil.addParameterToParameters(myFhirContext, theRetVal, "historical link");
287                final MdmLinkJson mdmLink = theMdmLinkRevision.getMdmLink();
288
289                ParametersUtil.addPartString(myFhirContext, resultPart, "goldenResourceId", mdmLink.getGoldenResourceId());
290                ParametersUtil.addPartString(
291                                myFhirContext,
292                                resultPart,
293                                "revisionTimestamp",
294                                theMdmLinkRevision.getRevisionTimestamp().toString());
295                ParametersUtil.addPartString(myFhirContext, resultPart, "sourceResourceId", mdmLink.getSourceId());
296                ParametersUtil.addPartString(
297                                myFhirContext,
298                                resultPart,
299                                "matchResult",
300                                mdmLink.getMatchResult().name());
301                ParametersUtil.addPartString(
302                                myFhirContext, resultPart, "linkSource", mdmLink.getLinkSource().name());
303                ParametersUtil.addPartBoolean(myFhirContext, resultPart, "eidMatch", mdmLink.getEidMatch());
304                ParametersUtil.addPartBoolean(
305                                myFhirContext, resultPart, "hadToCreateNewResource", mdmLink.getLinkCreatedNewResource());
306                ParametersUtil.addPartDecimal(myFhirContext, resultPart, "score", mdmLink.getScore());
307                ParametersUtil.addPartDecimal(myFhirContext, resultPart, "linkCreated", (double)
308                                mdmLink.getCreated().getTime());
309                ParametersUtil.addPartDecimal(myFhirContext, resultPart, "linkUpdated", (double)
310                                mdmLink.getUpdated().getTime());
311
312                IBase matchResultMapSubpart = ParametersUtil.createPart(myFhirContext, resultPart, "matchResultMap");
313
314                IBase matchedRulesSubpart = ParametersUtil.createPart(myFhirContext, matchResultMapSubpart, "matchedRules");
315                for (Map.Entry<String, MdmMatchResultEnum> entry : mdmLink.getRule()) {
316                        ParametersUtil.addPartString(myFhirContext, matchedRulesSubpart, "rule", entry.getKey());
317                }
318
319                ParametersUtil.addPartString(
320                                myFhirContext, matchResultMapSubpart, "initialMatchResult", theInitialAutoResult.name());
321        }
322
323        protected void addPagingParameters(
324                        IBaseParameters theParameters,
325                        Page<MdmLinkJson> theCurrentPage,
326                        ServletRequestDetails theServletRequestDetails,
327                        MdmPageRequest thePageRequest) {
328                MdmPageLinkTuple mdmPageLinkTuple =
329                                MdmPageLinkBuilder.buildMdmPageLinks(theServletRequestDetails, theCurrentPage, thePageRequest);
330
331                if (mdmPageLinkTuple.getPreviousLink().isPresent()) {
332                        ParametersUtil.addParameterToParametersUri(
333                                        myFhirContext,
334                                        theParameters,
335                                        "prev",
336                                        mdmPageLinkTuple.getPreviousLink().get());
337                }
338
339                ParametersUtil.addParameterToParametersUri(
340                                myFhirContext, theParameters, "self", mdmPageLinkTuple.getSelfLink());
341
342                if (mdmPageLinkTuple.getNextLink().isPresent()) {
343                        ParametersUtil.addParameterToParametersUri(
344                                        myFhirContext,
345                                        theParameters,
346                                        "next",
347                                        mdmPageLinkTuple.getNextLink().get());
348                }
349        }
350}