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