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.rules.config; 021 022import ca.uhn.fhir.context.ConfigurationException; 023import ca.uhn.fhir.context.FhirContext; 024import ca.uhn.fhir.context.FhirVersionEnum; 025import ca.uhn.fhir.context.RuntimeResourceDefinition; 026import ca.uhn.fhir.fhirpath.IFhirPath; 027import ca.uhn.fhir.i18n.Msg; 028import ca.uhn.fhir.mdm.api.IMdmRuleValidator; 029import ca.uhn.fhir.mdm.api.MdmConstants; 030import ca.uhn.fhir.mdm.rules.json.MdmFieldMatchJson; 031import ca.uhn.fhir.mdm.rules.json.MdmFilterSearchParamJson; 032import ca.uhn.fhir.mdm.rules.json.MdmResourceSearchParamJson; 033import ca.uhn.fhir.mdm.rules.json.MdmRulesJson; 034import ca.uhn.fhir.mdm.rules.json.MdmSimilarityJson; 035import ca.uhn.fhir.parser.DataFormatException; 036import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; 037import ca.uhn.fhir.util.FhirTerser; 038import ca.uhn.fhir.util.SearchParameterUtil; 039import org.hl7.fhir.instance.model.api.IBaseResource; 040import org.slf4j.Logger; 041import org.slf4j.LoggerFactory; 042import org.springframework.beans.factory.annotation.Autowired; 043import org.springframework.stereotype.Service; 044 045import java.net.URI; 046import java.net.URISyntaxException; 047import java.util.ArrayList; 048import java.util.HashSet; 049import java.util.List; 050import java.util.Set; 051 052@Service 053public class MdmRuleValidator implements IMdmRuleValidator { 054 private static final Logger ourLog = LoggerFactory.getLogger(MdmRuleValidator.class); 055 056 private final FhirContext myFhirContext; 057 private final ISearchParamRegistry mySearchParamRetriever; 058 private final FhirTerser myTerser; 059 private final IFhirPath myFhirPath; 060 061 @Autowired 062 public MdmRuleValidator(FhirContext theFhirContext, ISearchParamRegistry theSearchParamRetriever) { 063 myFhirContext = theFhirContext; 064 myTerser = myFhirContext.newTerser(); 065 if (myFhirContext.getVersion().getVersion().isEqualOrNewerThan(FhirVersionEnum.DSTU3)) { 066 myFhirPath = myFhirContext.newFhirPath(); 067 } else { 068 ourLog.debug("Skipping FHIRPath validation as DSTU2 does not support FHIR"); 069 myFhirPath = null; 070 } 071 mySearchParamRetriever = theSearchParamRetriever; 072 } 073 074 @Override 075 public void validate(MdmRulesJson theMdmRules) { 076 validateMdmTypes(theMdmRules); 077 validateSearchParams(theMdmRules); 078 validateMatchFields(theMdmRules); 079 validateSystemsAreUris(theMdmRules); 080 validateEidSystemsMatchMdmTypes(theMdmRules); 081 } 082 083 private void validateEidSystemsMatchMdmTypes(MdmRulesJson theMdmRules) { 084 theMdmRules.getEnterpriseEIDSystems().keySet().forEach(key -> { 085 // Ensure each key is either * or a valid resource type. 086 if (!key.equalsIgnoreCase("*") && !theMdmRules.getMdmTypes().contains(key)) { 087 throw new ConfigurationException(Msg.code(1507) 088 + String.format( 089 "There is an eidSystem set for [%s] but that is not one of the mdmTypes. Valid options are [%s].", 090 key, buildValidEidKeysMessage(theMdmRules))); 091 } 092 }); 093 } 094 095 private String buildValidEidKeysMessage(MdmRulesJson theMdmRulesJson) { 096 List<String> validTypes = new ArrayList<>(theMdmRulesJson.getMdmTypes()); 097 validTypes.add("*"); 098 return String.join(", ", validTypes); 099 } 100 101 private void validateSystemsAreUris(MdmRulesJson theMdmRules) { 102 theMdmRules.getEnterpriseEIDSystems().entrySet().forEach(entry -> { 103 String resourceType = entry.getKey(); 104 String uri = entry.getValue(); 105 if (!resourceType.equals("*")) { 106 try { 107 myFhirContext.getResourceType(resourceType); 108 } catch (DataFormatException e) { 109 throw new ConfigurationException(Msg.code(1508) 110 + String.format( 111 "%s is not a valid resource type, but is set in the eidSystems field.", 112 resourceType)); 113 } 114 } 115 validateIsUri(uri); 116 }); 117 } 118 119 public void validateMdmTypes(MdmRulesJson theMdmRulesJson) { 120 ourLog.info("Validating MDM types {}", theMdmRulesJson.getMdmTypes()); 121 122 if (theMdmRulesJson.getMdmTypes() == null) { 123 throw new ConfigurationException(Msg.code(1509) + "mdmTypes must be set to a list of resource types."); 124 } 125 for (String resourceType : theMdmRulesJson.getMdmTypes()) { 126 validateTypeHasIdentifier(resourceType); 127 } 128 } 129 130 public void validateTypeHasIdentifier(String theResourceType) { 131 if (mySearchParamRetriever.getActiveSearchParam( 132 theResourceType, "identifier", ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH) 133 == null) { 134 throw new ConfigurationException( 135 Msg.code(1510) + "Resource Type " + theResourceType 136 + " is not supported, as it does not have an 'identifier' field, which is necessary for MDM workflow."); 137 } 138 } 139 140 private void validateSearchParams(MdmRulesJson theMdmRulesJson) { 141 ourLog.info("Validating search parameters {}", theMdmRulesJson.getCandidateSearchParams()); 142 if (theMdmRulesJson.getCandidateSearchParams().isEmpty()) { 143 ourLog.warn("No candidate search parameter was found. Defining candidate search parameter is strongly " 144 + "recommended for better performance of MDM"); 145 } 146 for (MdmResourceSearchParamJson searchParams : theMdmRulesJson.getCandidateSearchParams()) { 147 searchParams 148 .iterator() 149 .forEachRemaining(searchParam -> 150 validateSearchParam("candidateSearchParams", searchParams.getResourceType(), searchParam)); 151 } 152 for (MdmFilterSearchParamJson filter : theMdmRulesJson.getCandidateFilterSearchParams()) { 153 validateSearchParam("candidateFilterSearchParams", filter.getResourceType(), filter.getSearchParam()); 154 } 155 } 156 157 private void validateSearchParam(String theFieldName, String theTheResourceType, String theTheSearchParam) { 158 if (MdmConstants.ALL_RESOURCE_SEARCH_PARAM_TYPE.equals(theTheResourceType)) { 159 validateResourceSearchParam(theFieldName, "Patient", theTheSearchParam); 160 validateResourceSearchParam(theFieldName, "Practitioner", theTheSearchParam); 161 } else { 162 validateResourceSearchParam(theFieldName, theTheResourceType, theTheSearchParam); 163 } 164 } 165 166 private void validateResourceSearchParam(String theFieldName, String theResourceType, String theSearchParam) { 167 String searchParam = SearchParameterUtil.stripModifier(theSearchParam); 168 if (mySearchParamRetriever.getActiveSearchParam( 169 theResourceType, searchParam, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH) 170 == null) { 171 throw new ConfigurationException(Msg.code(1511) + "Error in " + theFieldName + ": " + theResourceType 172 + " does not have a search parameter called '" + theSearchParam + "'"); 173 } 174 } 175 176 private void validateMatchFields(MdmRulesJson theMdmRulesJson) { 177 ourLog.info("Validating match fields {}", theMdmRulesJson.getMatchFields()); 178 179 Set<String> names = new HashSet<>(); 180 for (MdmFieldMatchJson fieldMatch : theMdmRulesJson.getMatchFields()) { 181 if (names.contains(fieldMatch.getName())) { 182 throw new ConfigurationException( 183 Msg.code(1512) + "Two MatchFields have the same name '" + fieldMatch.getName() + "'"); 184 } 185 names.add(fieldMatch.getName()); 186 if (fieldMatch.getSimilarity() != null) { 187 validateSimilarity(fieldMatch); 188 } else if (fieldMatch.getMatcher() == null) { 189 throw new ConfigurationException(Msg.code(1513) + "MatchField " + fieldMatch.getName() 190 + " has neither a similarity nor a matcher. At least one must be present."); 191 } 192 validatePath(theMdmRulesJson.getMdmTypes(), fieldMatch); 193 } 194 } 195 196 private void validateSimilarity(MdmFieldMatchJson theFieldMatch) { 197 MdmSimilarityJson similarity = theFieldMatch.getSimilarity(); 198 if (similarity.getMatchThreshold() == null) { 199 throw new ConfigurationException(Msg.code(1514) + "MatchField " + theFieldMatch.getName() + " similarity " 200 + similarity.getAlgorithm() + " requires a matchThreshold"); 201 } 202 } 203 204 private void validatePath(List<String> theMdmTypes, MdmFieldMatchJson theFieldMatch) { 205 String resourceType = theFieldMatch.getResourceType(); 206 207 if (MdmConstants.ALL_RESOURCE_SEARCH_PARAM_TYPE.equals(resourceType)) { 208 validateFieldPathForAllTypes(theMdmTypes, theFieldMatch); 209 } else { 210 validateFieldPath(theFieldMatch); 211 } 212 } 213 214 private void validateFieldPathForAllTypes(List<String> theMdmResourceTypes, MdmFieldMatchJson theFieldMatch) { 215 216 for (String resourceType : theMdmResourceTypes) { 217 validateFieldPathForType(resourceType, theFieldMatch); 218 } 219 } 220 221 private void validateFieldPathForType(String theResourceType, MdmFieldMatchJson theFieldMatch) { 222 ourLog.debug("Validating resource {} for {} ", theResourceType, theFieldMatch.getResourcePath()); 223 224 if (theFieldMatch.getFhirPath() != null && theFieldMatch.getResourcePath() != null) { 225 throw new ConfigurationException(Msg.code(1515) + "MatchField [" + theFieldMatch.getName() 226 + "] resourceType [" 227 + theFieldMatch.getResourceType() 228 + "] has defined both a resourcePath and a fhirPath. You must define one of the two."); 229 } 230 231 if (theFieldMatch.getResourcePath() == null && theFieldMatch.getFhirPath() == null) { 232 throw new ConfigurationException(Msg.code(1516) + "MatchField [" + theFieldMatch.getName() 233 + "] resourceType [" 234 + theFieldMatch.getResourceType() 235 + "] has defined neither a resourcePath or a fhirPath. You must define one of the two."); 236 } 237 238 if (theFieldMatch.getResourcePath() != null) { 239 try { // Try to validate the struture definition path 240 RuntimeResourceDefinition resourceDefinition = myFhirContext.getResourceDefinition(theResourceType); 241 Class<? extends IBaseResource> implementingClass = resourceDefinition.getImplementingClass(); 242 String path = theResourceType + "." + theFieldMatch.getResourcePath(); 243 myTerser.getDefinition(implementingClass, path); 244 } catch (DataFormatException | ConfigurationException | ClassCastException e) { 245 // Fallback to attempting to FHIRPath evaluate it. 246 throw new ConfigurationException(Msg.code(1517) + "MatchField " + theFieldMatch.getName() 247 + " resourceType " 248 + theFieldMatch.getResourceType() 249 + " has invalid path '" 250 + theFieldMatch.getResourcePath() + "'. " + e.getMessage()); 251 } 252 } else { // Try to validate the FHIRPath 253 try { 254 if (myFhirPath != null) { 255 myFhirPath.parse(theResourceType + "." + theFieldMatch.getFhirPath()); 256 } else { 257 ourLog.debug("Can't validate FHIRPath expression due to a lack of IFhirPath object."); 258 } 259 } catch (Exception e) { 260 throw new ConfigurationException(Msg.code(1518) + "MatchField [" + theFieldMatch.getName() 261 + "] resourceType [" + theFieldMatch.getResourceType() + "] has failed FHIRPath evaluation. " 262 + e.getMessage()); 263 } 264 } 265 } 266 267 private void validateFieldPath(MdmFieldMatchJson theFieldMatch) { 268 validateFieldPathForType(theFieldMatch.getResourceType(), theFieldMatch); 269 } 270 271 private void validateIsUri(String theUri) { 272 ourLog.info("Validating system URI {}", theUri); 273 try { 274 new URI(theUri); 275 } catch (URISyntaxException e) { 276 throw new ConfigurationException( 277 Msg.code(1519) + "Enterprise Identifier System (eidSystem) must be a valid URI"); 278 } 279 } 280}