001package ca.uhn.fhir.rest.server.interceptor.validation.address.impl;
002
003/*-
004 * #%L
005 * HAPI FHIR - Server Framework
006 * %%
007 * Copyright (C) 2014 - 2021 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}