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