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}