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}