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}