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}