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