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}