001package ca.uhn.fhir.rest.server.interceptor.validation.address.impl; 002 003/*- 004 * #%L 005 * HAPI FHIR - Server Framework 006 * %% 007 * Copyright (C) 2014 - 2022 Smile CDR, Inc. 008 * %% 009 * Licensed under the Apache License, Version 2.0 (the "License"); 010 * you may not use this file except in compliance with the License. 011 * You may obtain a copy of the License at 012 * 013 * http://www.apache.org/licenses/LICENSE-2.0 014 * 015 * Unless required by applicable law or agreed to in writing, software 016 * distributed under the License is distributed on an "AS IS" BASIS, 017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 018 * See the License for the specific language governing permissions and 019 * limitations under the License. 020 * #L% 021 */ 022 023import ca.uhn.fhir.context.FhirContext; 024import ca.uhn.fhir.rest.server.interceptor.validation.address.AddressValidationResult; 025import ca.uhn.fhir.rest.server.interceptor.validation.helpers.AddressHelper; 026import ca.uhn.fhir.util.ExtensionUtil; 027import ca.uhn.fhir.util.TerserUtil; 028import com.fasterxml.jackson.core.JsonProcessingException; 029import com.fasterxml.jackson.databind.JsonNode; 030import com.fasterxml.jackson.databind.ObjectMapper; 031import com.fasterxml.jackson.databind.node.ArrayNode; 032import com.fasterxml.jackson.databind.node.ObjectNode; 033import org.apache.commons.lang3.StringUtils; 034import org.apache.commons.lang3.Validate; 035import org.apache.http.entity.ContentType; 036import org.hl7.fhir.instance.model.api.IBase; 037import org.hl7.fhir.instance.model.api.IBaseExtension; 038import org.slf4j.Logger; 039import org.slf4j.LoggerFactory; 040import org.springframework.http.HttpEntity; 041import org.springframework.http.HttpHeaders; 042import org.springframework.http.ResponseEntity; 043 044import javax.annotation.Nullable; 045import java.math.BigDecimal; 046import java.util.Arrays; 047import java.util.Properties; 048import java.util.regex.Matcher; 049import java.util.regex.Pattern; 050 051import static ca.uhn.fhir.rest.server.interceptor.validation.address.IAddressValidator.ADDRESS_QUALITY_EXTENSION_URL; 052import static ca.uhn.fhir.rest.server.interceptor.validation.address.IAddressValidator.ADDRESS_VERIFICATION_CODE_EXTENSION_URL; 053 054/** 055 * For more details regarind the API refer to 056 * <a href="https://www.loqate.com/resources/support/cleanse-api/international-batch-cleanse/"> 057 * https://www.loqate.com/resources/support/cleanse-api/international-batch-cleanse/ 058 * </a> 059 */ 060public class LoquateAddressValidator extends BaseRestfulValidator { 061 062 private static final Logger ourLog = LoggerFactory.getLogger(LoquateAddressValidator.class); 063 064 public static final String PROPERTY_GEOCODE = "service.geocode"; 065 public static final String LOQUATE_AQI = "AQI"; 066 public static final String LOQUATE_AVC = "AVC"; 067 public static final String LOQUATE_GEO_ACCURACY = "GeoAccuracy"; 068 069 protected static final String[] DUPLICATE_FIELDS_IN_ADDRESS_LINES = {"Locality", "AdministrativeArea", "PostalCode"}; 070 protected static final String DEFAULT_DATA_CLEANSE_ENDPOINT = "https://api.addressy.com/Cleansing/International/Batch/v1.00/json4.ws"; 071 protected static final int MAX_ADDRESS_LINES = 8; 072 073 private Pattern myCommaPattern = Pattern.compile("\\,(\\S)"); 074 075 public LoquateAddressValidator(Properties theProperties) { 076 super(theProperties); 077 Validate.isTrue(theProperties.containsKey(PROPERTY_SERVICE_KEY) || theProperties.containsKey(PROPERTY_SERVICE_ENDPOINT), 078 "Expected service key or custom service endpoint in the configuration, but got " + theProperties); 079 } 080 081 @Override 082 protected AddressValidationResult getValidationResult(AddressValidationResult theResult, JsonNode response, FhirContext theFhirContext) { 083 Validate.isTrue(response.isArray() && response.size() >= 1, "Invalid response - expected to get an array of validated addresses"); 084 085 JsonNode firstMatch = response.get(0); 086 Validate.isTrue(firstMatch.has("Matches"), "Invalid response - matches are unavailable"); 087 088 JsonNode matches = firstMatch.get("Matches"); 089 Validate.isTrue(matches.isArray(), "Invalid response - expected to get a validated match in the response"); 090 091 JsonNode match = matches.get(0); 092 return toAddressValidationResult(theResult, match, theFhirContext); 093 } 094 095 private AddressValidationResult toAddressValidationResult(AddressValidationResult theResult, JsonNode theMatch, FhirContext theFhirContext) { 096 theResult.setValid(isValid(theMatch)); 097 098 ourLog.debug("Address validation flag {}", theResult.isValid()); 099 JsonNode addressNode = theMatch.get("Address"); 100 if (addressNode != null) { 101 theResult.setValidatedAddressString(addressNode.asText()); 102 } 103 104 ourLog.debug("Validated address string {}", theResult.getValidatedAddressString()); 105 theResult.setValidatedAddress(toAddress(theMatch, theFhirContext)); 106 return theResult; 107 } 108 109 protected boolean isValid(JsonNode theMatch) { 110 String addressQualityIndex = getField(theMatch, LOQUATE_AQI); 111 return "A".equals(addressQualityIndex) || "B".equals(addressQualityIndex) || "C".equals(addressQualityIndex); 112 } 113 114 private String getField(JsonNode theMatch, String theFieldName) { 115 String field = null; 116 if (theMatch.has(theFieldName)) { 117 field = theMatch.get(theFieldName).asText(); 118 } 119 ourLog.debug("Found {}={}", theFieldName, field); 120 return field; 121 } 122 123 protected IBase toAddress(JsonNode match, FhirContext theFhirContext) { 124 IBase addressBase = theFhirContext.getElementDefinition("Address").newInstance(); 125 126 AddressHelper helper = new AddressHelper(theFhirContext, addressBase); 127 helper.setText(standardize(getString(match, "Address"))); 128 129 String str = getString(match, "Address1"); 130 if (str != null) { 131 helper.addLine(str); 132 } 133 134 if (isGeocodeEnabled()) { 135 toGeolocation(match, helper, theFhirContext); 136 } 137 138 removeDuplicateAddressLines(match, helper); 139 140 helper.setCity(getString(match, "Locality")); 141 helper.setState(getString(match, "AdministrativeArea")); 142 helper.setPostalCode(getString(match, "PostalCode")); 143 helper.setCountry(getString(match, "CountryName")); 144 145 addExtension(match, LOQUATE_AQI, ADDRESS_QUALITY_EXTENSION_URL, helper, theFhirContext); 146 addExtension(match, LOQUATE_AVC, ADDRESS_VERIFICATION_CODE_EXTENSION_URL, helper, theFhirContext); 147 addExtension(match, LOQUATE_GEO_ACCURACY, ADDRESS_GEO_ACCURACY_EXTENSION_URL, helper, theFhirContext); 148 149 return helper.getAddress(); 150 } 151 152 private void addExtension(JsonNode theMatch, String theMatchField, String theExtUrl, AddressHelper theHelper, FhirContext theFhirContext) { 153 String addressQuality = getField(theMatch, theMatchField); 154 if (StringUtils.isEmpty(addressQuality)) { 155 ourLog.debug("{} is not found in {}", theMatchField, theMatch); 156 return; 157 } 158 159 IBase address = theHelper.getAddress(); 160 ExtensionUtil.clearExtensionsByUrl(address, theExtUrl); 161 162 IBaseExtension addressQualityExt = ExtensionUtil.addExtension(address, theExtUrl); 163 addressQualityExt.setValue(TerserUtil.newElement(theFhirContext, "string", addressQuality)); 164 } 165 166 private void toGeolocation(JsonNode theMatch, AddressHelper theHelper, FhirContext theFhirContext) { 167 if (!theMatch.has("Latitude") || !theMatch.has("Longitude")) { 168 ourLog.warn("Geocode is not provided in JSON {}", theMatch); 169 return; 170 } 171 172 IBase address = theHelper.getAddress(); 173 ExtensionUtil.clearExtensionsByUrl(address, FHIR_GEOCODE_EXTENSION_URL); 174 IBaseExtension geolocation = ExtensionUtil.addExtension(address, FHIR_GEOCODE_EXTENSION_URL); 175 176 IBaseExtension latitude = ExtensionUtil.addExtension(geolocation, "latitude"); 177 latitude.setValue(TerserUtil.newElement(theFhirContext, "decimal", 178 BigDecimal.valueOf(theMatch.get("Latitude").asDouble()))); 179 180 IBaseExtension longitude = ExtensionUtil.addExtension(geolocation, "longitude"); 181 longitude.setValue(TerserUtil.newElement(theFhirContext, "decimal", 182 BigDecimal.valueOf(theMatch.get("Longitude").asDouble()))); 183 } 184 185 private void removeDuplicateAddressLines(JsonNode match, AddressHelper address) { 186 int lineCount = 1; 187 String addressLine = null; 188 while ((addressLine = getString(match, "Address" + ++lineCount)) != null) { 189 if (isDuplicate(addressLine, match)) { 190 continue; 191 } 192 address.addLine(addressLine); 193 } 194 } 195 196 private boolean isDuplicate(String theAddressLine, JsonNode theMatch) { 197 for (String s : DUPLICATE_FIELDS_IN_ADDRESS_LINES) { 198 JsonNode node = theMatch.get(s); 199 if (node == null) { 200 continue; 201 } 202 theAddressLine = theAddressLine.replaceAll(node.asText(), ""); 203 } 204 return theAddressLine.trim().isEmpty(); 205 } 206 207 @Nullable 208 protected String getString(JsonNode theNode, String theField) { 209 if (!theNode.has(theField)) { 210 return null; 211 } 212 213 JsonNode field = theNode.get(theField); 214 if (field.asText().isEmpty()) { 215 return null; 216 } 217 218 String text = theNode.get(theField).asText(); 219 if (StringUtils.isEmpty(text)) { 220 return ""; 221 } 222 return text; 223 } 224 225 protected String standardize(String theText) { 226 if (StringUtils.isEmpty(theText)) { 227 return ""; 228 } 229 230 theText = theText.replaceAll("\\s\\s", ", "); 231 Matcher m = myCommaPattern.matcher(theText); 232 if (m.find()) { 233 theText = m.replaceAll(", $1"); 234 } 235 return theText.trim(); 236 } 237 238 @Override 239 protected ResponseEntity<String> getResponseEntity(IBase theAddress, FhirContext theFhirContext) throws Exception { 240 HttpHeaders headers = new HttpHeaders(); 241 headers.set(HttpHeaders.ACCEPT, ContentType.APPLICATION_JSON.getMimeType()); 242 headers.set(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.getMimeType()); 243 headers.set(HttpHeaders.USER_AGENT, "SmileCDR"); 244 245 String requestBody = getRequestBody(theFhirContext, theAddress); 246 HttpEntity<String> request = new HttpEntity<>(requestBody, headers); 247 return newTemplate().postForEntity(getApiEndpoint(), request, String.class); 248 } 249 250 @Override 251 protected String getApiEndpoint() { 252 String endpoint = super.getApiEndpoint(); 253 return StringUtils.isEmpty(endpoint) ? DEFAULT_DATA_CLEANSE_ENDPOINT : endpoint; 254 } 255 256 protected String getRequestBody(FhirContext theFhirContext, IBase... theAddresses) throws JsonProcessingException { 257 ObjectMapper mapper = new ObjectMapper(); 258 ObjectNode rootNode = mapper.createObjectNode(); 259 if (!StringUtils.isEmpty(getApiKey())) { 260 rootNode.put("Key", getApiKey()); 261 } 262 rootNode.put("Geocode", isGeocodeEnabled()); 263 264 ArrayNode addressesArrayNode = mapper.createArrayNode(); 265 int i = 0; 266 for (IBase address : theAddresses) { 267 ourLog.debug("Converting {} out of {} addresses", i++, theAddresses.length); 268 ObjectNode addressNode = toJsonNode(address, mapper, theFhirContext); 269 addressesArrayNode.add(addressNode); 270 } 271 rootNode.set("Addresses", addressesArrayNode); 272 return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(rootNode); 273 } 274 275 protected ObjectNode toJsonNode(IBase theAddress, ObjectMapper mapper, FhirContext theFhirContext) { 276 AddressHelper helper = new AddressHelper(theFhirContext, theAddress); 277 ObjectNode addressNode = mapper.createObjectNode(); 278 279 int count = 1; 280 for (String s : helper.getMultiple("line")) { 281 addressNode.put("Address" + count, s); 282 count++; 283 284 if (count > MAX_ADDRESS_LINES) { 285 break; 286 } 287 } 288 addressNode.put("Locality", helper.getCity()); 289 addressNode.put("PostalCode", helper.getPostalCode()); 290 addressNode.put("Country", helper.getCountry()); 291 return addressNode; 292 } 293 294 protected boolean isGeocodeEnabled() { 295 if (!getProperties().containsKey(PROPERTY_GEOCODE)) { 296 return false; 297 } 298 return Boolean.parseBoolean(getProperties().getProperty(PROPERTY_GEOCODE)); 299 } 300}