diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ExtensionUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ExtensionUtil.java new file mode 100644 index 00000000000..d10a4497b9d --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ExtensionUtil.java @@ -0,0 +1,178 @@ +package ca.uhn.fhir.util; + +/*- + * #%L + * HAPI FHIR - Core Library + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + + +import ca.uhn.fhir.context.FhirContext; +import org.hl7.fhir.instance.model.api.IBase; +import org.hl7.fhir.instance.model.api.IBaseDatatype; +import org.hl7.fhir.instance.model.api.IBaseExtension; +import org.hl7.fhir.instance.model.api.IBaseHasExtensions; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Utility for modifying with extensions in a FHIR version-independent approach. + */ +public class ExtensionUtil { + + /** + * Returns an extension with the specified URL creating one if it doesn't exist. + * + * @param theBase Base resource to get extension from + * @param theUrl URL for the extension + * @return Returns a extension with the specified URL. + * @throws IllegalArgumentException IllegalArgumentException is thrown in case resource doesn't support extensions + */ + public static IBaseExtension getOrCreateExtension(IBase theBase, String theUrl) { + IBaseHasExtensions baseHasExtensions = validateExtensionSupport(theBase); + IBaseExtension extension = getExtension(baseHasExtensions, theUrl); + if (extension == null) { + extension = baseHasExtensions.addExtension(); + extension.setUrl(theUrl); + } + return extension; + } + + private static IBaseHasExtensions validateExtensionSupport(IBase theBase) { + if (!(theBase instanceof IBaseHasExtensions)) { + throw new IllegalArgumentException(String.format("Expected instance that supports extensions, but got %s", theBase)); + } + return (IBaseHasExtensions) theBase; + } + + /** + * Checks if the specified instance has an extension with the specified URL + * + * @param theBase The base resource to check extensions on + * @param theExtensionUrl URL of the extension + * @return Returns true if extension is exists and false otherwise + */ + public static boolean hasExtension(IBase theBase, String theExtensionUrl) { + IBaseHasExtensions baseHasExtensions; + try { + baseHasExtensions = validateExtensionSupport(theBase); + } catch (Exception e) { + return false; + } + + return getExtension(baseHasExtensions, theExtensionUrl) != null; + } + + /** + * Checks if the specified instance has an extension with the specified URL + * + * @param theBase The base resource to check extensions on + * @param theExtensionUrl URL of the extension + * @return Returns true if extension is exists and false otherwise + */ + public static boolean hasExtension(IBase theBase, String theExtensionUrl, String theExtensionValue) { + if (!hasExtension(theBase, theExtensionUrl)) { + return false; + } + IBaseDatatype value = getExtension((IBaseHasExtensions) theBase, theExtensionUrl).getValue(); + if (value == null) { + return theExtensionValue == null; + } + return value.toString().equals(theExtensionValue); + } + + /** + * Gets the first extension with the specified URL + * + * @param theBase The resource to get the extension for + * @param theExtensionUrl URL of the extension to get. Must be non-null + * @return Returns the first available extension with the specified URL, or null if such extension doesn't exist + */ + public static IBaseExtension getExtension(IBaseHasExtensions theBase, String theExtensionUrl) { + return theBase.getExtension() + .stream() + .filter(e -> theExtensionUrl.equals(e.getUrl())) + .findFirst() + .orElse(null); + } + + /** + * Gets all extensions with the specified URL + * + * @param theBase The resource to get the extension for + * @param theExtensionUrl URL of the extension to get. Must be non-null + * @return Returns all extension with the specified URL, or an empty list if such extensions do not exist + */ + public static List> getExtensions(IBaseHasExtensions theBase, String theExtensionUrl) { + return theBase.getExtension() + .stream() + .filter(e -> theExtensionUrl.equals(e.getUrl())) + .collect(Collectors.toList()); + } + + /** + * Sets value of the extension as a string + * + * @param theExtension The extension to set the value on + * @param theValue The value to set + * @param theFhirContext The context containing FHIR resource definitions + */ + public static void setExtension(FhirContext theFhirContext, IBaseExtension theExtension, String theValue) { + setExtension(theFhirContext, theExtension, "string", theValue); + } + + /** + * Sets value of the extension + * + * @param theExtension The extension to set the value on + * @param theExtensionType Element type of the extension + * @param theValue The value to set + * @param theFhirContext The context containing FHIR resource definitions + */ + public static void setExtension(FhirContext theFhirContext, IBaseExtension theExtension, String theExtensionType, String theValue) { + theExtension.setValue(TerserUtil.newElement(theFhirContext, theExtensionType, theValue)); + } + + /** + * Sets or replaces existing extension with the specified value as a string + * + * @param theBase The resource to update extension on + * @param theUrl Extension URL + * @param theValue Extension value + * @param theFhirContext The context containing FHIR resource definitions + */ + public static void setExtension(FhirContext theFhirContext, IBase theBase, String theUrl, String theValue) { + IBaseExtension ext = getOrCreateExtension(theBase, theUrl); + setExtension(theFhirContext, ext, theValue); + } + + /** + * Sets or replaces existing extension with the specified value + * + * @param theBase The resource to update extension on + * @param theUrl Extension URL + * @param theValueType Type of the value to set in the extension + * @param theValue Extension value + * @param theFhirContext The context containing FHIR resource definitions + */ + public static void setExtension(FhirContext theFhirContext, IBase theBase, String theUrl, String theValueType, String theValue) { + IBaseExtension ext = getOrCreateExtension(theBase, theUrl); + setExtension(theFhirContext, ext, theValue); + } + +} diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/PrimitiveTypeEqualsPredicate.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/PrimitiveTypeEqualsPredicate.java new file mode 100644 index 00000000000..51f5a6b25ff --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/PrimitiveTypeEqualsPredicate.java @@ -0,0 +1,91 @@ +package ca.uhn.fhir.util; + +/*- + * #%L + * HAPI FHIR - Core Library + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import org.hl7.fhir.instance.model.api.IPrimitiveType; + +import java.lang.reflect.Field; +import java.util.function.BiPredicate; + +/** + * Boolean-value function for comparing two FHIR primitives via .equals() method on the instance + * internal values. + */ +public class PrimitiveTypeEqualsPredicate implements BiPredicate { + + /** + * Returns true if both bases are of the same type and hold the same values. + */ + @Override + public boolean test(Object theBase1, Object theBase2) { + if (theBase1 == null) { + return theBase2 == null; + } + if (theBase2 == null) { + return false; + } + if (!theBase1.getClass().equals(theBase2.getClass())) { + return false; + } + + for (Field f : theBase1.getClass().getDeclaredFields()) { + Class fieldClass = f.getType(); + + if (!IPrimitiveType.class.isAssignableFrom(fieldClass)) { + continue; + } + + IPrimitiveType val1, val2; + + f.setAccessible(true); + try { + val1 = (IPrimitiveType) f.get(theBase1); + val2 = (IPrimitiveType) f.get(theBase2); + } catch (Exception e) { + // swallow + continue; + } + + if (val1 == null && val2 == null) { + continue; + } + + if (val1 == null || val2 == null) { + return false; + } + + Object actualVal1 = val1.getValue(); + Object actualVal2 = val2.getValue(); + + if (actualVal1 == null && actualVal2 == null) { + continue; + } + if (actualVal1 == null) { + return false; + } + if (!actualVal1.equals(actualVal2)) { + return false; + } + } + + return true; + } +} diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/PropertyModifyingHelper.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/PropertyModifyingHelper.java new file mode 100644 index 00000000000..b1be439493a --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/PropertyModifyingHelper.java @@ -0,0 +1,191 @@ +package ca.uhn.fhir.util; + +/*- + * #%L + * HAPI FHIR - Core Library + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.context.FhirContext; +import org.apache.commons.lang3.StringUtils; +import org.hl7.fhir.instance.model.api.IBase; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Helper class for handling updates of the instances that support property modification via setProperty + * and getProperty methods. + */ +public class PropertyModifyingHelper { + + public static final String GET_PROPERTY_METHOD_NAME = "getProperty"; + public static final String SET_PROPERTY_METHOD_NAME = "setProperty"; + public static final String DEFAULT_DELIMITER = ", "; + + private IBase myBase; + + private String myDelimiter = DEFAULT_DELIMITER; + + private FhirContext myFhirContext; + + /** + * Creates a new instance initializing the dependencies. + * + * @param theFhirContext FHIR context holding the resource definitions + * @param theBase The base class to set properties on + */ + public PropertyModifyingHelper(FhirContext theFhirContext, IBase theBase) { + if (findGetPropertyMethod(theBase) == null) { + throw new IllegalArgumentException("Specified base instance does not support property retrieval."); + } + myBase = theBase; + myFhirContext = theFhirContext; + } + + /** + * Gets the method with the specified name and parameter types. + * + * @param theObject Non-null instance to get the method from + * @param theMethodName Name of the method to get + * @param theParamClasses Parameters types that method parameters should be assignable as + * @return Returns the method with the given name and parameters or null if it can't be found + */ + protected Method getMethod(Object theObject, String theMethodName, Class... theParamClasses) { + for (Method m : theObject.getClass().getDeclaredMethods()) { + if (m.getName().equals(theMethodName)) { + if (theParamClasses.length == 0) { + return m; + } + if (m.getParameterCount() != theParamClasses.length) { + continue; + } + for (int i = 0; i < theParamClasses.length; i++) { + if (!m.getParameterTypes()[i].isAssignableFrom(theParamClasses[i])) { + continue; + } + } + return m; + } + } + return null; + } + + /** + * Gets all non-blank fields as a single string joined with the delimiter provided by {@link #getDelimiter()} + * + * @param theFiledNames Field names to retrieve values for + * @return Returns all specified non-blank fileds as a single string. + */ + public String getFields(String... theFiledNames) { + return Arrays.stream(theFiledNames) + .map(this::get) + .filter(s -> !StringUtils.isBlank(s)) + .collect(Collectors.joining(getDelimiter())); + } + + /** + * Gets property with the specified name from the provided base class. + * + * @param thePropertyName Name of the property to get + * @return Returns property value converted to string. In case of multiple values, they are joined with the + * specified delimiter. + */ + public String get(String thePropertyName) { + return getMultiple(thePropertyName) + .stream() + .collect(Collectors.joining(getDelimiter())); + } + + /** + * Sets property or adds to a collection of properties with the specified name from the provided base class. + * + * @param thePropertyName Name of the property to set or add element to in case property is a collection + */ + public void set(String thePropertyName, String theValue) { + if (theValue == null || theValue.isEmpty()) { + return; + } + + try { + IBase value = myFhirContext.getElementDefinition("string").newInstance(theValue); + Method setPropertyMethod = findSetPropertyMethod(myBase, int.class, String.class, value.getClass()); + int hashCode = thePropertyName.hashCode(); + setPropertyMethod.invoke(myBase, hashCode, thePropertyName, value); + } catch (Exception e) { + throw new IllegalStateException(String.format("Unable to set property %s on %s", thePropertyName, myBase), e); + } + } + + /** + * Gets property values with the specified name from the provided base class. + * + * @param thePropertyName Name of the property to get + * @return Returns property values converted to string. + */ + public List getMultiple(String thePropertyName) { + Method getPropertyMethod = findGetPropertyMethod(myBase); + Object[] values; + try { + values = (Object[]) getPropertyMethod.invoke(myBase, thePropertyName.hashCode(), thePropertyName, true); + } catch (Exception e) { + throw new IllegalStateException(String.format("Instance %s does not supply property %s", myBase, thePropertyName), e); + } + + return Arrays.stream(values) + .map(String::valueOf) + .filter(s -> !StringUtils.isEmpty(s)) + .collect(Collectors.toList()); + } + + private Method findGetPropertyMethod(IBase theAddress) { + return getMethod(theAddress, GET_PROPERTY_METHOD_NAME); + } + + private Method findSetPropertyMethod(IBase theAddress, Class... theParamClasses) { + return getMethod(theAddress, SET_PROPERTY_METHOD_NAME, theParamClasses); + } + + /** + * Gets the delimiter used when concatenating multiple field values + * + * @return Returns the delimiter + */ + public String getDelimiter() { + return myDelimiter; + } + + /** + * Sets the delimiter used when concatenating multiple field values + * + * @param theDelimiter The delimiter to set + */ + public void setDelimiter(String theDelimiter) { + this.myDelimiter = theDelimiter; + } + + /** + * Gets the base instance that this helper operates on + * + * @return Returns the base instance + */ + public IBase getBase() { + return myBase; + } +} diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtil.java new file mode 100644 index 00000000000..6d204df45e3 --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtil.java @@ -0,0 +1,481 @@ +package ca.uhn.fhir.util; + +/*- + * #%L + * HAPI FHIR - Core Library + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.context.BaseRuntimeChildDefinition; +import ca.uhn.fhir.context.BaseRuntimeElementCompositeDefinition; +import ca.uhn.fhir.context.BaseRuntimeElementDefinition; +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.RuntimeResourceDefinition; +import org.hl7.fhir.instance.model.api.IBase; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.slf4j.Logger; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.slf4j.LoggerFactory.getLogger; + +public final class TerserUtil { + + private static final Logger ourLog = getLogger(TerserUtil.class); + + public static final String FIELD_NAME_IDENTIFIER = "identifier"; + + public static final Collection IDS_AND_META_EXCLUDES = + Collections.unmodifiableSet(Stream.of("id", "identifier", "meta").collect(Collectors.toSet())); + + public static final Predicate EXCLUDE_IDS_AND_META = new Predicate() { + @Override + public boolean test(String s) { + return !IDS_AND_META_EXCLUDES.contains(s); + } + }; + + public static final Predicate INCLUDE_ALL = new Predicate() { + @Override + public boolean test(String s) { + return true; + } + }; + + private TerserUtil() { + } + + /** + * Given an Child Definition of `identifier`, a R4/DSTU3 EID Identifier, and a new resource, clone the EID into that resources' identifier list. + */ + public static void cloneEidIntoResource(FhirContext theFhirContext, BaseRuntimeChildDefinition theIdentifierDefinition, IBase theEid, IBase theResourceToCloneEidInto) { + // FHIR choice types - fields within fhir where we have a choice of ids + BaseRuntimeElementCompositeDefinition childIdentifier = (BaseRuntimeElementCompositeDefinition) theIdentifierDefinition.getChildByName(FIELD_NAME_IDENTIFIER); + IBase resourceNewIdentifier = childIdentifier.newInstance(); + + FhirTerser terser = theFhirContext.newTerser(); + terser.cloneInto(theEid, resourceNewIdentifier, true); + theIdentifierDefinition.getMutator().addValue(theResourceToCloneEidInto, resourceNewIdentifier); + } + + /** + * Checks if the specified fields has any values + * + * @param theFhirContext Context holding resource definition + * @param theResource Resource to check if the specified field is set + * @param theFieldName name of the field to check + * @return Returns true if field exists and has any values set, and false otherwise + */ + public static boolean hasValues(FhirContext theFhirContext, IBaseResource theResource, String theFieldName) { + RuntimeResourceDefinition resourceDefinition = theFhirContext.getResourceDefinition(theResource); + BaseRuntimeChildDefinition resourceIdentifier = resourceDefinition.getChildByName(theFieldName); + if (resourceIdentifier == null) { + return false; + } + return !(resourceIdentifier.getAccessor().getValues(theResource).isEmpty()); + } + + /** + * get the Values of a specified field. + * + * @param theFhirContext Context holding resource definition + * @param theResource Resource to check if the specified field is set + * @param theFieldName name of the field to check + * @return Returns true if field exists and has any values set, and false otherwise + */ + public static List getValues(FhirContext theFhirContext, IBaseResource theResource, String theFieldName) { + RuntimeResourceDefinition resourceDefinition = theFhirContext.getResourceDefinition(theResource); + BaseRuntimeChildDefinition resourceIdentifier = resourceDefinition.getChildByName(theFieldName); + if (resourceIdentifier == null) { + ourLog.info("There is no field named {} in Resource {}", theFieldName, resourceDefinition.getName()); + return null; + } + return resourceIdentifier.getAccessor().getValues(theResource); + } + + /** + * Clones specified composite field (collection). Composite field values must conform to the collections + * contract. + * + * @param theFrom Resource to clone the specified field from + * @param theTo Resource to clone the specified field to + * @param theField Field name to be copied + */ + public static void cloneCompositeField(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo, String theField) { + FhirTerser terser = theFhirContext.newTerser(); + + RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theFrom); + BaseRuntimeChildDefinition childDefinition = definition.getChildByName(theField); + if (childDefinition == null) { + throw new IllegalArgumentException(String.format("Unable to find child definition %s in %s", theField, theFrom)); + } + + List theFromFieldValues = childDefinition.getAccessor().getValues(theFrom); + List theToFieldValues = childDefinition.getAccessor().getValues(theTo); + + for (IBase theFromFieldValue : theFromFieldValues) { + if (containsPrimitiveValue(theFromFieldValue, theToFieldValues)) { + continue; + } + + IBase newFieldValue = childDefinition.getChildByName(theField).newInstance(); + terser.cloneInto(theFromFieldValue, newFieldValue, true); + + try { + theToFieldValues.add(newFieldValue); + } catch (Exception e) { + childDefinition.getMutator().setValue(theTo, newFieldValue); + } + } + } + + private static boolean containsPrimitiveValue(IBase theItem, List theItems) { + PrimitiveTypeEqualsPredicate predicate = new PrimitiveTypeEqualsPredicate(); + return theItems.stream().anyMatch(i -> { + return predicate.test(i, theItem); + }); + } + + private static boolean contains(IBase theItem, List theItems) { + Method method = null; + for (Method m : theItem.getClass().getDeclaredMethods()) { + if (m.getName().equals("equalsDeep")) { + method = m; + break; + } + } + + final Method m = method; + return theItems.stream().anyMatch(i -> { + if (m != null) { + try { + return (Boolean) m.invoke(theItem, i); + } catch (Exception e) { + throw new RuntimeException("Unable to compare equality via equalsDeep", e); + } + } + return theItem.equals(i); + }); + } + + /** + * Merges all fields on the provided instance. theTo will contain a union of all values from theFrom + * instance and theTo instance. + * + * @param theFhirContext Context holding resource definition + * @param theFrom The resource to merge the fields from + * @param theTo The resource to merge the fields into + */ + public static void mergeAllFields(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo) { + mergeFields(theFhirContext, theFrom, theTo, INCLUDE_ALL); + } + + /** + * Replaces all fields that test positive by the given inclusion strategy. theTo will contain a copy of the + * values from theFrom instance. + * + * @param theFhirContext Context holding resource definition + * @param theFrom The resource to merge the fields from + * @param theTo The resource to merge the fields into + * @param inclusionStrategy Inclusion strategy that checks if a given field should be replaced by checking {@link Predicate#test(Object)} + */ + public static void replaceFields(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo, Predicate inclusionStrategy) { + FhirTerser terser = theFhirContext.newTerser(); + + RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theFrom); + for (BaseRuntimeChildDefinition childDefinition : definition.getChildrenAndExtension()) { + if (!inclusionStrategy.test(childDefinition.getElementName())) { + continue; + } + + replaceField(theFrom, theTo, childDefinition); + } + } + + /** + * Checks if the field exists on the resource + * + * @param theFhirContext Context holding resource definition + * @param theFieldName Name of the field to check + * @param theInstance Resource instance to check + * @return Returns true if resource definition has a child with the specified name and false otherwise + */ + public static boolean fieldExists(FhirContext theFhirContext, String theFieldName, IBaseResource theInstance) { + RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theInstance); + return definition.getChildByName(theFieldName) != null; + } + + /** + * Replaces the specified fields on theTo resource with the value from theFrom resource. + * + * @param theFhirContext Context holding resource definition + * @param theFrom The resource to replace the field from + * @param theTo The resource to replace the field on + */ + public static void replaceField(FhirContext theFhirContext, String theFieldName, IBaseResource theFrom, IBaseResource theTo) { + replaceField(theFhirContext, theFhirContext.newTerser(), theFieldName, theFrom, theTo); + } + + /** + * @deprecated Use {@link #replaceField(FhirContext, String, IBaseResource, IBaseResource)} instead + */ + public static void replaceField(FhirContext theFhirContext, FhirTerser theTerser, String theFieldName, IBaseResource theFrom, IBaseResource theTo) { + replaceField(theFrom, theTo, getBaseRuntimeChildDefinition(theFhirContext, theFieldName, theFrom)); + } + + /** + * Clears the specified field on the resource provided + * + * @param theFhirContext Context holding resource definition + * @param theFieldName + * @param theResource + */ + public static void clearField(FhirContext theFhirContext, String theFieldName, IBaseResource theResource) { + BaseRuntimeChildDefinition childDefinition = getBaseRuntimeChildDefinition(theFhirContext, theFieldName, theResource); + childDefinition.getAccessor().getValues(theResource).clear(); + } + + /** + * Sets the provided field with the given values. This method will add to the collection of existing field values + * in case of multiple cardinality. Use {@link #clearField(FhirContext, FhirTerser, String, IBaseResource, IBase...)} + * to remove values before setting + * + * @param theFhirContext Context holding resource definition + * @param theTerser Terser to be used when cloning field values + * @param theFieldName Child field name of the resource to set + * @param theResource The resource to set the values on + * @param theValues The values to set on the resource child field name + */ + public static void setField(FhirContext theFhirContext, FhirTerser theTerser, String theFieldName, IBaseResource theResource, IBase... theValues) { + BaseRuntimeChildDefinition childDefinition = getBaseRuntimeChildDefinition(theFhirContext, theFieldName, theResource); + + List theFromFieldValues = childDefinition.getAccessor().getValues(theResource); + List theToFieldValues = Arrays.asList(theValues); + + mergeFields(theTerser, theResource, childDefinition, theFromFieldValues, theToFieldValues); + } + + /** + * Sets the specified value at the FHIR path provided. + * + * @param theTerser The terser that should be used for cloning the field value. + * @param theFhirPath The FHIR path to set the field at + * @param theResource The resource on which the value should be set + * @param theValue The value to set + */ + public static void setFieldByFhirPath(FhirTerser theTerser, String theFhirPath, IBaseResource theResource, IBase theValue) { + List theFromFieldValues = theTerser.getValues(theResource, theFhirPath, true, false); + for (IBase theFromFieldValue : theFromFieldValues) { + theTerser.cloneInto(theValue, theFromFieldValue, true); + } + } + + /** + * Sets the specified value at the FHIR path provided. + * + * @param theFhirContext Context holding resource definition + * @param theFhirPath The FHIR path to set the field at + * @param theResource The resource on which the value should be set + * @param theValue The value to set + */ + public static void setFieldByFhirPath(FhirContext theFhirContext, String theFhirPath, IBaseResource theResource, IBase theValue) { + setFieldByFhirPath(theFhirContext.newTerser(), theFhirPath, theResource, theValue); + } + + private static void replaceField(IBaseResource theFrom, IBaseResource theTo, BaseRuntimeChildDefinition childDefinition) { + childDefinition.getAccessor().getFirstValueOrNull(theFrom).ifPresent(v -> { + childDefinition.getMutator().setValue(theTo, v); + } + ); + } + + /** + * Merges values of all fields except for "identifier" and "meta" from theFrom resource to + * theTo resource. Fields values are compared via the equalsDeep method, or via object identity if this + * method is not available. + * + * @param theFhirContext Context holding resource definition + * @param theFrom Resource to merge the specified field from + * @param theTo Resource to merge the specified field into + */ + public static void mergeFieldsExceptIdAndMeta(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo) { + mergeFields(theFhirContext, theFrom, theTo, EXCLUDE_IDS_AND_META); + } + + /** + * Merges values of all field from theFrom resource to theTo resource. Fields + * values are compared via the equalsDeep method, or via object identity if this method is not available. + * + * @param theFhirContext Context holding resource definition + * @param theFrom Resource to merge the specified field from + * @param theTo Resource to merge the specified field into + * @param inclusionStrategy Predicate to test which fields should be merged + */ + public static void mergeFields(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo, Predicate inclusionStrategy) { + FhirTerser terser = theFhirContext.newTerser(); + + RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theFrom); + for (BaseRuntimeChildDefinition childDefinition : definition.getChildrenAndExtension()) { + if (!inclusionStrategy.test(childDefinition.getElementName())) { + continue; + } + + List theFromFieldValues = childDefinition.getAccessor().getValues(theFrom); + List theToFieldValues = childDefinition.getAccessor().getValues(theTo); + + mergeFields(terser, theTo, childDefinition, theFromFieldValues, theToFieldValues); + } + } + + /** + * Merges value of the specified field from theFrom resource to theTo resource. Fields + * values are compared via the equalsDeep method, or via object identity if this method is not available. + * + * @param theFhirContext Context holding resource definition + * @param theFieldName Name of the child filed to merge + * @param theFrom Resource to merge the specified field from + * @param theTo Resource to merge the specified field into + */ + public static void mergeField(FhirContext theFhirContext, String theFieldName, IBaseResource theFrom, IBaseResource theTo) { + mergeField(theFhirContext, theFhirContext.newTerser(), theFieldName, theFrom, theTo); + } + + /** + * Merges value of the specified field from theFrom resource to theTo resource. Fields + * values are compared via the equalsDeep method, or via object identity if this method is not available. + * + * @param theFhirContext Context holding resource definition + * @param theTerser Terser to be used when cloning the field values + * @param theFieldName Name of the child filed to merge + * @param theFrom Resource to merge the specified field from + * @param theTo Resource to merge the specified field into + */ + public static void mergeField(FhirContext theFhirContext, FhirTerser theTerser, String theFieldName, IBaseResource theFrom, IBaseResource theTo) { + BaseRuntimeChildDefinition childDefinition = getBaseRuntimeChildDefinition(theFhirContext, theFieldName, theFrom); + + List theFromFieldValues = childDefinition.getAccessor().getValues(theFrom); + List theToFieldValues = childDefinition.getAccessor().getValues(theTo); + + mergeFields(theTerser, theTo, childDefinition, theFromFieldValues, theToFieldValues); + } + + private static BaseRuntimeChildDefinition getBaseRuntimeChildDefinition(FhirContext theFhirContext, String theFieldName, IBaseResource theFrom) { + RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theFrom); + BaseRuntimeChildDefinition childDefinition = definition.getChildByName(theFieldName); + if (childDefinition == null) { + throw new IllegalStateException(String.format("Field %s does not exist", theFieldName)); + } + return childDefinition; + } + + private static void mergeFields(FhirTerser theTerser, IBaseResource theTo, BaseRuntimeChildDefinition childDefinition, List theFromFieldValues, List theToFieldValues) { + for (IBase theFromFieldValue : theFromFieldValues) { + if (contains(theFromFieldValue, theToFieldValues)) { + continue; + } + + IBase newFieldValue = childDefinition.getChildByName(childDefinition.getElementName()).newInstance(); + theTerser.cloneInto(theFromFieldValue, newFieldValue, true); + + try { + theToFieldValues.add(newFieldValue); + } catch (UnsupportedOperationException e) { + childDefinition.getMutator().setValue(theTo, newFieldValue); + break; + } + } + } + + /** + * Clones the specified resource. + * + * @param theFhirContext Context holding resource definition + * @param theInstance The instance to be cloned + * @param Base resource type + * @return Returns a cloned instance + */ + public static T clone(FhirContext theFhirContext, T theInstance) { + RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theInstance.getClass()); + T retVal = (T) definition.newInstance(); + + FhirTerser terser = theFhirContext.newTerser(); + terser.cloneInto(theInstance, retVal, true); + return retVal; + } + + /** + * Creates a new element instance + * + * @param theFhirContext Context holding resource definition + * @param theElementType Element type name + * @param Base element type + * @return Returns a new instance of the element + */ + public static T newElement(FhirContext theFhirContext, String theElementType) { + BaseRuntimeElementDefinition def = theFhirContext.getElementDefinition(theElementType); + return (T) def.newInstance(); + } + + /** + * Creates a new element instance + * + * @param theFhirContext Context holding resource definition + * @param theElementType Element type name + * @param theConstructorParam Initialization parameter for the element + * @param Base element type + * @return Returns a new instance of the element with the specified initial value + */ + public static T newElement(FhirContext theFhirContext, String theElementType, Object theConstructorParam) { + BaseRuntimeElementDefinition def = theFhirContext.getElementDefinition(theElementType); + return (T) def.newInstance(theConstructorParam); + } + + /** + * Creates a new resource definition. + * + * @param theFhirContext Context holding resource definition + * @param theResourceName Name of the resource in the context + * @param Type of the resource + * @return Returns a new instance of the resource + */ + public static T newResource(FhirContext theFhirContext, String theResourceName) { + RuntimeResourceDefinition def = theFhirContext.getResourceDefinition(theResourceName); + return (T) def.newInstance(); + } + + /** + * Creates a new resource definition. + * + * @param theFhirContext Context holding resource definition + * @param theResourceName Name of the resource in the context + * @param theConstructorParam Initialization parameter for the new instance + * @param Type of the resource + * @return Returns a new instance of the resource + */ + public static T newResource(FhirContext theFhirContext, String theResourceName, Object theConstructorParam) { + RuntimeResourceDefinition def = theFhirContext.getResourceDefinition(theResourceName); + return (T) def.newInstance(theConstructorParam); + } + +} diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtilHelper.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtilHelper.java new file mode 100644 index 00000000000..3cecd163bcc --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtilHelper.java @@ -0,0 +1,170 @@ +package ca.uhn.fhir.util; + +/*- + * #%L + * HAPI FHIR - Core Library + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.RuntimeResourceDefinition; +import org.hl7.fhir.instance.model.api.IBase; +import org.hl7.fhir.instance.model.api.IBaseResource; + +import java.util.List; + +/** + * Wrapper class holding context-related instances, and the resource being operated on. Sample use case is + * + *
{@code
+ * TerserUtilHelper helper = TerserUtilHelper.newHelper(ourFhirContext, "Patient");
+ * helper.setField("identifier.system", "http://org.com/sys");
+ * helper.setField("identifier.value", "123");
+ * ...
+ * Patient patient = helper.getResource();
+ * }
+ */ +public class TerserUtilHelper { + + /** + * Factory method for creating a new instance of the wrapper + * + * @param theFhirContext FHIR Context to be used for all further operations + * @param theResourceName Name of the resource type + * @return Returns a new helper instance + */ + public static TerserUtilHelper newHelper(FhirContext theFhirContext, String theResourceName) { + return newHelper(theFhirContext, (IBaseResource) TerserUtil.newResource(theFhirContext, theResourceName)); + } + + /** + * Factory method for creating a new instance of the wrapper + * + * @param theFhirContext FHIR Context to be used for all further operations + * @param theResource The resource to operate on + * @return Returns a new helper instance + */ + public static TerserUtilHelper newHelper(FhirContext theFhirContext, IBaseResource theResource) { + TerserUtilHelper retVal = new TerserUtilHelper(theFhirContext, theResource); + return retVal; + } + + private FhirContext myContext; + private FhirTerser myTerser; + private IBaseResource myResource; + + protected TerserUtilHelper(FhirContext theFhirContext, IBaseResource theResource) { + myContext = theFhirContext; + myResource = theResource; + } + + /** + * Sets string field at the specified FHIR path + * + * @param theField The FHIR Path to set the values at + * @param theValue The string value to be set + * @return Returns current instance + */ + public TerserUtilHelper setField(String theField, String theValue) { + IBase value = newStringElement(theValue); + TerserUtil.setFieldByFhirPath(getTerser(), theField, myResource, value); + return this; + } + + /** + * Sets field at the specified FHIR path + * + * @param theField The FHIR Path to set the values at + * @param theValue The value to be set + * @return Returns current instance + */ + public TerserUtilHelper setField(String theField, String theFieldType, Object theValue) { + IBase value = newElement(theFieldType, theValue); + TerserUtil.setFieldByFhirPath(getTerser(), theField, myResource, value); + return this; + } + + protected IBase newStringElement(String theValue) { + return newElement("string", theValue); + } + + protected IBase newElement(String theElementType, Object theValue) { + IBase value = TerserUtil.newElement(myContext, theElementType, theValue); + return value; + } + + /** + * Gets values of the specified field. + * + * @param theField The field to get values from + * @return Returns a collection of values containing values or null if the spefied field doesn't exist + */ + public List getFieldValues(String theField) { + return TerserUtil.getValues(myContext, myResource, theField); + } + + /** + * Gets the terser instance, creating one if necessary. + * + * @return Returns the terser + */ + public FhirTerser getTerser() { + if (myTerser == null) { + myTerser = myContext.newTerser(); + } + return myTerser; + } + + /** + * Gets resource that this helper operates on + * + * @param Instance type of the resource + * @return Returns the resources + */ + public T getResource() { + return (T) myResource; + } + + /** + * Gets runtime definition for the resource + * + * @return Returns resource definition. + */ + public RuntimeResourceDefinition getResourceDefinition() { + return myContext.getResourceDefinition(myResource); + } + + /** + * Creates a new element + * + * @param theElementName Name of the element to create + * @return Returns a new element + */ + public IBase newElement(String theElementName) { + return TerserUtil.newElement(myContext, theElementName); + } + + /** + * Gets context holding resource definition. + * + * @return Returns the current FHIR context. + */ + public FhirContext getContext() { + return myContext; + } + +} diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/5_4_0/2449-interceptors-to-handle-standardization-normalization-and-address-and-field-level-validation.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/5_4_0/2449-interceptors-to-handle-standardization-normalization-and-address-and-field-level-validation.yaml new file mode 100644 index 00000000000..900e4266f9d --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/5_4_0/2449-interceptors-to-handle-standardization-normalization-and-address-and-field-level-validation.yaml @@ -0,0 +1,7 @@ +--- +type: add +issue: 2449 +title: "Adds interceptors for the following functionality: +* Data normalization (n11n) - removing unwanted characters (control, etc. as defined by the requirements) +* Data standardization (s13n) - normalizing data by ensuring word spacing and character cases are uniform +* Data validation - making sure that addresses / emails are validated" diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/docs/interceptors/built_in_server_interceptors.md b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/docs/interceptors/built_in_server_interceptors.md index 4d2e524b6a5..f430e7367d5 100644 --- a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/docs/interceptors/built_in_server_interceptors.md +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/docs/interceptors/built_in_server_interceptors.md @@ -227,3 +227,52 @@ The UserRequestRetryVersionConflictsInterceptor allows clients to request that t # JPA Server: Validate Data Being Stored The RepositoryValidatingInterceptor can be used to enforce validation rules on data stored in a HAPI FHIR JPA Repository. See [Repository Validating Interceptor](/docs/validation/repository_validating_interceptor.html) for more information. + + +# Data Standardization + +```StandardizingInterceptor``` handles data standardization (s13n) requirements. This interceptor applies standardization rules on all FHIR primitives as configured in the ```s13n.json``` file that should be made available on the classpath. This file contains FHIRPath definitions together with the standardizers that should be applied to that path. It comes with six per-build standardizers: NAME_FAMILY, NAME_GIVEN, EMAIL, TITLE, PHONE and TEXT. Custom standardizers can be developed by implementing ```ca.uhn.fhir.rest.server.interceptor.s13n.standardizers.IStandardizer``` interface. + +A sample configuration file can be found below: + +```json +{ + "Person" : { + "Person.name.family" : "NAME_FAMILY", + "Person.name.given" : "NAME_GIVEN", + "Person.telecom.where(system='phone').value" : "PHONE" + }, + "Patient" : { + "name.family" : "NAME_FAMILY", + "name.given" : "NAME_GIVEN", + "telecom.where(system='phone').value" : "PHONE" + }, + "*" : { + "telecom.where(system='email').value" : "org.example.s13n.MyCustomStandardizer" + } +} +``` + +Standardization can be disabled for a given request by providing ```HAPI-Standardization-Disabled: *``` request header. Header value can be any string, it is the presence of the header that disables the s13n. + + +# Validation: Address Validation + +```AddressValidatingInterceptor``` takes care of validation of addresses on all incoming resources through a 3rd party address validation service. Before a resource containing an Address field is stored, this interceptor invokes address validation service and then stores validation results as an extension on the address with ```https://hapifhir.org/AddressValidation/``` URL. + +This interceptor is configured in ```address-validation.properties``` file that should be made available on the classpath. This file must contain ```validator.class``` property, which defines a fully qualified class implementing ```ca.uhn.fhir.rest.server.interceptor.validation.address.IAddressValidator``` interface. The specified implementation must provide service-specific logic for validating an Address instance. An example implementation can be found in ```ca.uhn.fhir.rest.server.interceptor.validation.address.impl.LoquateAddressValidator``` class which validates addresses by using Loquate Data Cleanse service. + +Address validation can be disabled for a given request by providing ```HAPI-Address-Validation-Disabled: *``` request header. Header value can be any string, it is the presence of the header that disables the validation. + +# Validation: Field-Level Validation + +```FieldValidatingInterceptor``` allows validating primitive fields on various FHIR resources. It expects validation rules to be provided via ```field-validation-rules.json``` file that should be available on the classpath. JSON in this file defines a mapping of FHIRPath expressions to validators that should be applied to those fields. Custom validators that implement ```ca.uhn.fhir.rest.server.interceptor.validation.fields.IValidator``` interface can be provided. + +```json +{ + "telecom.where(system='email').value" : "EMAIL", + "telecom.where(system='phone').value" : "org.example.validation.MyCustomValidator" +} +``` + +Field validation can be disabled for a given request by providing ```HAPI-Field-Validation-Disabled: *``` request header. Header value can be any string, it is the presence of the header that disables the validation. diff --git a/hapi-fhir-server-mdm/src/main/java/ca/uhn/fhir/mdm/api/MdmConstants.java b/hapi-fhir-server-mdm/src/main/java/ca/uhn/fhir/mdm/api/MdmConstants.java index 8a7ee1a74c6..45b3349736e 100644 --- a/hapi-fhir-server-mdm/src/main/java/ca/uhn/fhir/mdm/api/MdmConstants.java +++ b/hapi-fhir-server-mdm/src/main/java/ca/uhn/fhir/mdm/api/MdmConstants.java @@ -21,13 +21,14 @@ package ca.uhn.fhir.mdm.api; */ public class MdmConstants { + /** * TAG system for Golden Resources which are managed by HAPI MDM. */ public static final String SYSTEM_MDM_MANAGED = "https://hapifhir.org/NamingSystem/managing-mdm-system"; public static final String CODE_HAPI_MDM_MANAGED = "HAPI-MDM"; - public static final String DISPLAY_HAPI_MDM_MANAGED = "This Golden Resource can only be modified by Smile CDR's MDM system."; + public static final String DISPLAY_HAPI_MDM_MANAGED = "This Golden Resource can only be modified by HAPI MDM system."; public static final String CODE_NO_MDM_MANAGED = "NO-MDM"; public static final String HAPI_ENTERPRISE_IDENTIFIER_SYSTEM = "http://hapifhir.io/fhir/NamingSystem/mdm-golden-resource-enterprise-id"; public static final String ALL_RESOURCE_SEARCH_PARAM_TYPE = "*"; diff --git a/hapi-fhir-server-mdm/src/main/java/ca/uhn/fhir/mdm/util/GoldenResourceHelper.java b/hapi-fhir-server-mdm/src/main/java/ca/uhn/fhir/mdm/util/GoldenResourceHelper.java index 0ffbfb91758..fb8c4196769 100644 --- a/hapi-fhir-server-mdm/src/main/java/ca/uhn/fhir/mdm/util/GoldenResourceHelper.java +++ b/hapi-fhir-server-mdm/src/main/java/ca/uhn/fhir/mdm/util/GoldenResourceHelper.java @@ -73,8 +73,9 @@ public class GoldenResourceHelper { /** * Creates a copy of the specified resource. This method will carry over resource EID if it exists. If it does not exist, * a randomly generated UUID EID will be created. - * @param Supported MDM resource type (e.g. Patient, Practitioner) - * @param theIncomingResource The resource that will be used as the starting point for the MDM linking. + * + * @param Supported MDM resource type (e.g. Patient, Practitioner) + * @param theIncomingResource The resource that will be used as the starting point for the MDM linking. * @param theMdmTransactionContext */ public T createGoldenResourceFromMdmSourceResource(T theIncomingResource, MdmTransactionContext theMdmTransactionContext) { @@ -115,7 +116,7 @@ public class GoldenResourceHelper { theGoldenResourceIdentifier.getMutator().addValue(theNewGoldenResource, IdentifierUtil.toId(myFhirContext, hapiEid)); // set identifier on the source resource - TerserUtil.cloneEidIntoResource(myFhirContext, theSourceResource, hapiEid); + cloneEidIntoResource(myFhirContext, theSourceResource, hapiEid); } private void cloneAllExternalEidsIntoNewGoldenResource(BaseRuntimeChildDefinition theGoldenResourceIdentifier, @@ -130,7 +131,7 @@ public class GoldenResourceHelper { String mdmSystem = myMdmSettings.getMdmRules().getEnterpriseEIDSystem(); String baseSystem = system.get().getValueAsString(); if (Objects.equals(baseSystem, mdmSystem)) { - TerserUtil.cloneEidIntoResource(myFhirContext, theGoldenResourceIdentifier, base, theNewGoldenResource); + ca.uhn.fhir.util.TerserUtil.cloneEidIntoResource(myFhirContext, theGoldenResourceIdentifier, base, theNewGoldenResource); ourLog.debug("System {} differs from system in the MDM rules {}", baseSystem, mdmSystem); } } else { @@ -235,18 +236,18 @@ public class GoldenResourceHelper { for (CanonicalEID incomingExternalEid : theIncomingSourceExternalEids) { if (goldenResourceExternalEids.contains(incomingExternalEid)) { continue; - } else { - TerserUtil.cloneEidIntoResource(myFhirContext, theGoldenResource, incomingExternalEid); } + cloneEidIntoResource(myFhirContext, theGoldenResource, incomingExternalEid); } } public boolean hasIdentifier(IBaseResource theResource) { - return TerserUtil.hasValues(myFhirContext, theResource, FIELD_NAME_IDENTIFIER); + return ca.uhn.fhir.util.TerserUtil.hasValues(myFhirContext, theResource, FIELD_NAME_IDENTIFIER); } public void mergeIndentifierFields(IBaseResource theFromGoldenResource, IBaseResource theToGoldenResource, MdmTransactionContext theMdmTransactionContext) { - TerserUtil.cloneCompositeField(myFhirContext, theFromGoldenResource, theToGoldenResource, FIELD_NAME_IDENTIFIER); + ca.uhn.fhir.util.TerserUtil.cloneCompositeField(myFhirContext, theFromGoldenResource, theToGoldenResource, + FIELD_NAME_IDENTIFIER); } public void mergeNonIdentiferFields(IBaseResource theFromGoldenResource, IBaseResource theToGoldenResource, MdmTransactionContext theMdmTransactionContext) { @@ -275,4 +276,20 @@ public class GoldenResourceHelper { updateGoldenResourceExternalEidFromSourceResource(theGoldenResource, theSourceResource, theMdmTransactionContext); } } + + /** + * Clones the specified canonical EID into the identifier field on the resource + * + * @param theFhirContext Context to pull resource definitions from + * @param theResourceToCloneInto Resource to set the EID on + * @param theEid EID to be set + */ + public void cloneEidIntoResource(FhirContext theFhirContext, IBaseResource theResourceToCloneInto, CanonicalEID theEid) { + // get a ref to the actual ID Field + RuntimeResourceDefinition resourceDefinition = theFhirContext.getResourceDefinition(theResourceToCloneInto); + // hapi has 2 metamodels: for children and types + BaseRuntimeChildDefinition resourceIdentifier = resourceDefinition.getChildByName(FIELD_NAME_IDENTIFIER); + ca.uhn.fhir.util.TerserUtil.cloneEidIntoResource(theFhirContext, resourceIdentifier, + IdentifierUtil.toId(theFhirContext, theEid), theResourceToCloneInto); + } } diff --git a/hapi-fhir-server-mdm/src/main/java/ca/uhn/fhir/mdm/util/TerserUtil.java b/hapi-fhir-server-mdm/src/main/java/ca/uhn/fhir/mdm/util/TerserUtil.java index 1dd411cbdd4..d64a55396ea 100644 --- a/hapi-fhir-server-mdm/src/main/java/ca/uhn/fhir/mdm/util/TerserUtil.java +++ b/hapi-fhir-server-mdm/src/main/java/ca/uhn/fhir/mdm/util/TerserUtil.java @@ -21,296 +21,127 @@ package ca.uhn.fhir.mdm.util; */ import ca.uhn.fhir.context.BaseRuntimeChildDefinition; -import ca.uhn.fhir.context.BaseRuntimeElementCompositeDefinition; import ca.uhn.fhir.context.FhirContext; -import ca.uhn.fhir.context.RuntimeResourceDefinition; import ca.uhn.fhir.mdm.model.CanonicalEID; import ca.uhn.fhir.util.FhirTerser; import org.hl7.fhir.instance.model.api.IBase; import org.hl7.fhir.instance.model.api.IBaseResource; import org.slf4j.Logger; -import java.lang.reflect.Method; import java.util.Collection; -import java.util.Collections; import java.util.List; import java.util.function.Predicate; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import static ca.uhn.fhir.mdm.util.GoldenResourceHelper.FIELD_NAME_IDENTIFIER; import static org.slf4j.LoggerFactory.getLogger; +@Deprecated public final class TerserUtil { private static final Logger ourLog = getLogger(TerserUtil.class); - public static final Collection IDS_AND_META_EXCLUDES = - Collections.unmodifiableSet(Stream.of("id", "identifier", "meta").collect(Collectors.toSet())); + public static final Collection IDS_AND_META_EXCLUDES = ca.uhn.fhir.util.TerserUtil.IDS_AND_META_EXCLUDES; - public static final Predicate EXCLUDE_IDS_AND_META = new Predicate() { - @Override - public boolean test(String s) { - return !IDS_AND_META_EXCLUDES.contains(s); - } - }; - - public static final Predicate INCLUDE_ALL = new Predicate() { - @Override - public boolean test(String s) { - return true; - } - }; + public static final Predicate EXCLUDE_IDS_AND_META = ca.uhn.fhir.util.TerserUtil.EXCLUDE_IDS_AND_META; private TerserUtil() { } /** - * Clones the specified canonical EID into the identifier field on the resource - * - * @param theFhirContext Context to pull resource definitions from - * @param theResourceToCloneInto Resource to set the EID on - * @param theEid EID to be set - */ - public static void cloneEidIntoResource(FhirContext theFhirContext, IBaseResource theResourceToCloneInto, CanonicalEID theEid) { - // get a ref to the actual ID Field - RuntimeResourceDefinition resourceDefinition = theFhirContext.getResourceDefinition(theResourceToCloneInto); - // hapi has 2 metamodels: for children and types - BaseRuntimeChildDefinition resourceIdentifier = resourceDefinition.getChildByName(FIELD_NAME_IDENTIFIER); - cloneEidIntoResource(theFhirContext, resourceIdentifier, IdentifierUtil.toId(theFhirContext, theEid), theResourceToCloneInto); - } - - /** - * Given an Child Definition of `identifier`, a R4/DSTU3 EID Identifier, and a new resource, clone the EID into that resources' identifier list. + * @deprecated Use {@link ca.uhn.fhir.util.TerserUtil} instead */ public static void cloneEidIntoResource(FhirContext theFhirContext, BaseRuntimeChildDefinition theIdentifierDefinition, IBase theEid, IBase theResourceToCloneEidInto) { - // FHIR choice types - fields within fhir where we have a choice of ids - BaseRuntimeElementCompositeDefinition childIdentifier = (BaseRuntimeElementCompositeDefinition) theIdentifierDefinition.getChildByName(FIELD_NAME_IDENTIFIER); - IBase resourceNewIdentifier = childIdentifier.newInstance(); - - FhirTerser terser = theFhirContext.newTerser(); - terser.cloneInto(theEid, resourceNewIdentifier, true); - theIdentifierDefinition.getMutator().addValue(theResourceToCloneEidInto, resourceNewIdentifier); + ca.uhn.fhir.util.TerserUtil.cloneEidIntoResource(theFhirContext, theIdentifierDefinition, theEid, theResourceToCloneEidInto); } /** - * Checks if the specified fields has any values - * - * @param theFhirContext Context holding resource definition - * @param theResource Resource to check if the specified field is set - * @param theFieldName name of the field to check - * @return Returns true if field exists and has any values set, and false otherwise + * @deprecated Use {@link ca.uhn.fhir.util.TerserUtil} instead */ public static boolean hasValues(FhirContext theFhirContext, IBaseResource theResource, String theFieldName) { - RuntimeResourceDefinition resourceDefinition = theFhirContext.getResourceDefinition(theResource); - BaseRuntimeChildDefinition resourceIdentifier = resourceDefinition.getChildByName(theFieldName); - if (resourceIdentifier == null) { - return false; - } - return !(resourceIdentifier.getAccessor().getValues(theResource).isEmpty()); + return ca.uhn.fhir.util.TerserUtil.hasValues(theFhirContext, theResource, theFieldName); } + /** - * get the Values of a specified field. - * - * @param theFhirContext Context holding resource definition - * @param theResource Resource to check if the specified field is set - * @param theFieldName name of the field to check - * @return Returns true if field exists and has any values set, and false otherwise + * @deprecated Use {@link ca.uhn.fhir.util.TerserUtil} instead */ public static List getValues(FhirContext theFhirContext, IBaseResource theResource, String theFieldName) { - RuntimeResourceDefinition resourceDefinition = theFhirContext.getResourceDefinition(theResource); - BaseRuntimeChildDefinition resourceIdentifier = resourceDefinition.getChildByName(theFieldName); - if (resourceIdentifier == null) { - ourLog.info("There is no field named {} in Resource {}", theFieldName, resourceDefinition.getName()); - return null; - } - return resourceIdentifier.getAccessor().getValues(theResource); + return ca.uhn.fhir.util.TerserUtil.getValues(theFhirContext, theResource, theFieldName); } /** - * Clones specified composite field (collection). Composite field values must confirm to the collections - * contract. - * - * @param theFrom Resource to clone the specified filed from - * @param theTo Resource to clone the specified filed to - * @param field Field name to be copied + * @deprecated Use {@link ca.uhn.fhir.util.TerserUtil} instead */ public static void cloneCompositeField(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo, String field) { - FhirTerser terser = theFhirContext.newTerser(); + ca.uhn.fhir.util.TerserUtil.cloneCompositeField(theFhirContext, theFrom, theTo, field); - RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theFrom); - BaseRuntimeChildDefinition childDefinition = definition.getChildByName(field); - - List theFromFieldValues = childDefinition.getAccessor().getValues(theFrom); - List theToFieldValues = childDefinition.getAccessor().getValues(theTo); - - for (IBase theFromFieldValue : theFromFieldValues) { - if (containsPrimitiveValue(theFromFieldValue, theToFieldValues)) { - continue; - } - - IBase newFieldValue = childDefinition.getChildByName(field).newInstance(); - terser.cloneInto(theFromFieldValue, newFieldValue, true); - - try { - theToFieldValues.add(newFieldValue); - } catch (Exception e) { - childDefinition.getMutator().setValue(theTo, newFieldValue); - } - } - } - - private static boolean containsPrimitiveValue(IBase theItem, List theItems) { - PrimitiveTypeEqualsPredicate predicate = new PrimitiveTypeEqualsPredicate(); - return theItems.stream().anyMatch(i -> { - return predicate.test(i, theItem); - }); - } - - private static boolean contains(IBase theItem, List theItems) { - Method method = null; - for (Method m : theItem.getClass().getDeclaredMethods()) { - if (m.getName().equals("equalsDeep")) { - method = m; - break; - } - } - - final Method m = method; - return theItems.stream().anyMatch(i -> { - if (m != null) { - try { - return (Boolean) m.invoke(theItem, i); - } catch (Exception e) { - throw new RuntimeException("Unable to compare equality via equalsDeep", e); - } - } - return theItem.equals(i); - }); - } - - public static void mergeAllFields(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo) { - mergeFields(theFhirContext, theFrom, theTo, INCLUDE_ALL); - } - - public static void replaceFields(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo, Predicate inclusionStrategy) { - FhirTerser terser = theFhirContext.newTerser(); - - RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theFrom); - for (BaseRuntimeChildDefinition childDefinition : definition.getChildrenAndExtension()) { - if (!inclusionStrategy.test(childDefinition.getElementName())) { - continue; - } - - replaceField(theFrom, theTo, childDefinition); - } - } - - public static boolean fieldExists(FhirContext theFhirContext, String theFieldName, IBaseResource theInstance) { - RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theInstance); - return definition.getChildByName(theFieldName) != null; - } - - public static void replaceField(FhirContext theFhirContext, String theFieldName, IBaseResource theFrom, IBaseResource theTo) { - replaceField(theFhirContext, theFhirContext.newTerser(), theFieldName, theFrom, theTo); - } - - public static void replaceField(FhirContext theFhirContext, FhirTerser theTerser, String theFieldName, IBaseResource theFrom, IBaseResource theTo) { - replaceField(theFrom, theTo, getBaseRuntimeChildDefinition(theFhirContext, theFieldName, theFrom)); - } - - private static void replaceField(IBaseResource theFrom, IBaseResource theTo, BaseRuntimeChildDefinition childDefinition) { - childDefinition.getAccessor().getFirstValueOrNull(theFrom).ifPresent(v -> { - childDefinition.getMutator().setValue(theTo, v); - } - ); - } - - public static void mergeFieldsExceptIdAndMeta(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo) { - mergeFields(theFhirContext, theFrom, theTo, EXCLUDE_IDS_AND_META); - } - - public static void mergeFields(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo, Predicate inclusionStrategy) { - FhirTerser terser = theFhirContext.newTerser(); - - RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theFrom); - for (BaseRuntimeChildDefinition childDefinition : definition.getChildrenAndExtension()) { - if (!inclusionStrategy.test(childDefinition.getElementName())) { - continue; - } - - List theFromFieldValues = childDefinition.getAccessor().getValues(theFrom); - List theToFieldValues = childDefinition.getAccessor().getValues(theTo); - - mergeFields(terser, theTo, childDefinition, theFromFieldValues, theToFieldValues); - } } /** - * Merges value of the specified field from theFrom resource to theTo resource. Fields values are compared via - * the equalsDeep method, or via object identity if this method is not available. - * - * @param theFhirContext - * @param theFieldName - * @param theFrom - * @param theTo + * @deprecated Use {@link ca.uhn.fhir.util.TerserUtil} instead + */ + public static void mergeAllFields(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo) { + ca.uhn.fhir.util.TerserUtil.mergeAllFields(theFhirContext, theFrom, theTo); + } + + /** + * @deprecated Use {@link ca.uhn.fhir.util.TerserUtil} instead + */ + public static void replaceFields(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo, Predicate inclusionStrategy) { + ca.uhn.fhir.util.TerserUtil.replaceFields(theFhirContext, theFrom, theTo, inclusionStrategy); + } + + /** + * @deprecated Use {@link ca.uhn.fhir.util.TerserUtil} instead + */ + public static boolean fieldExists(FhirContext theFhirContext, String theFieldName, IBaseResource theInstance) { + return ca.uhn.fhir.util.TerserUtil.fieldExists(theFhirContext, theFieldName, theInstance); + } + + /** + * @deprecated Use {@link ca.uhn.fhir.util.TerserUtil} instead + */ + public static void replaceField(FhirContext theFhirContext, String theFieldName, IBaseResource theFrom, IBaseResource theTo) { + ca.uhn.fhir.util.TerserUtil.replaceField(theFhirContext, theFieldName, theFrom, theTo); + } + + /** + * @deprecated Use {@link ca.uhn.fhir.util.TerserUtil} instead + */ + public static void replaceField(FhirContext theFhirContext, FhirTerser theTerser, String theFieldName, IBaseResource theFrom, IBaseResource theTo) { + ca.uhn.fhir.util.TerserUtil.replaceField(theFhirContext, theTerser, theFieldName, theFrom, theTo); + } + + /** + * @deprecated Use {@link ca.uhn.fhir.util.TerserUtil} instead + */ + public static void mergeFieldsExceptIdAndMeta(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo) { + ca.uhn.fhir.util.TerserUtil.mergeFieldsExceptIdAndMeta(theFhirContext, theFrom, theTo); + } + + /** + * @deprecated Use {@link ca.uhn.fhir.util.TerserUtil} instead + */ + public static void mergeFields(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo, Predicate inclusionStrategy) { + ca.uhn.fhir.util.TerserUtil.mergeFields(theFhirContext, theFrom, theTo, inclusionStrategy); + } + + /** + * @deprecated Use {@link ca.uhn.fhir.util.TerserUtil} instead */ public static void mergeField(FhirContext theFhirContext, String theFieldName, IBaseResource theFrom, IBaseResource theTo) { - mergeField(theFhirContext, theFhirContext.newTerser(), theFieldName, theFrom, theTo); + ca.uhn.fhir.util.TerserUtil.mergeField(theFhirContext, theFieldName, theFrom, theTo); } /** - * Merges value of the specified field from theFrom resource to theTo resource. Fields values are compared via - * the equalsDeep method, or via object identity if this method is not available. - * - * @param theFhirContext - * @param theTerser - * @param theFieldName - * @param theFrom - * @param theTo + * @deprecated Use {@link ca.uhn.fhir.util.TerserUtil} instead */ public static void mergeField(FhirContext theFhirContext, FhirTerser theTerser, String theFieldName, IBaseResource theFrom, IBaseResource theTo) { - BaseRuntimeChildDefinition childDefinition = getBaseRuntimeChildDefinition(theFhirContext, theFieldName, theFrom); - - List theFromFieldValues = childDefinition.getAccessor().getValues(theFrom); - List theToFieldValues = childDefinition.getAccessor().getValues(theTo); - - mergeFields(theTerser, theTo, childDefinition, theFromFieldValues, theToFieldValues); - } - - private static BaseRuntimeChildDefinition getBaseRuntimeChildDefinition(FhirContext theFhirContext, String theFieldName, IBaseResource theFrom) { - RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theFrom); - BaseRuntimeChildDefinition childDefinition = definition.getChildByName(theFieldName); - if (childDefinition == null) { - throw new IllegalStateException(String.format("Field %s does not exist", theFieldName)); - } - return childDefinition; - } - - private static void mergeFields(FhirTerser theTerser, IBaseResource theTo, BaseRuntimeChildDefinition childDefinition, List theFromFieldValues, List theToFieldValues) { - for (IBase theFromFieldValue : theFromFieldValues) { - if (contains(theFromFieldValue, theToFieldValues)) { - continue; - } - - IBase newFieldValue = childDefinition.getChildByName(childDefinition.getElementName()).newInstance(); - theTerser.cloneInto(theFromFieldValue, newFieldValue, true); - - try { - theToFieldValues.add(newFieldValue); - } catch (UnsupportedOperationException e) { - childDefinition.getMutator().setValue(theTo, newFieldValue); - break; - } - } + ca.uhn.fhir.util.TerserUtil.mergeField(theFhirContext, theTerser, theFieldName, theFrom, theTo); } + /** + * @deprecated Use {@link ca.uhn.fhir.util.TerserUtil} instead + */ public static T clone(FhirContext theFhirContext, T theInstance) { - RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theInstance.getClass()); - T retVal = (T) definition.newInstance(); - - FhirTerser terser = theFhirContext.newTerser(); - terser.cloneInto(theInstance, retVal, true); - return retVal; + return ca.uhn.fhir.util.TerserUtil.clone(theFhirContext, theInstance); } } diff --git a/hapi-fhir-server-mdm/src/test/java/ca/uhn/fhir/mdm/util/TerserUtilTest.java b/hapi-fhir-server-mdm/src/test/java/ca/uhn/fhir/mdm/util/TerserUtilTest.java index 338fe934024..a94891fbdd2 100644 --- a/hapi-fhir-server-mdm/src/test/java/ca/uhn/fhir/mdm/util/TerserUtilTest.java +++ b/hapi-fhir-server-mdm/src/test/java/ca/uhn/fhir/mdm/util/TerserUtilTest.java @@ -42,6 +42,9 @@ class TerserUtilTest extends BaseR4Test { @Test void testCloneFields() { Patient p1 = buildJohny(); + p1.addName().addGiven("Sigizmund"); + p1.setId("Patient/22"); + Patient p2 = new Patient(); TerserUtil.mergeFieldsExceptIdAndMeta(ourFhirContext, p1, p2); @@ -54,7 +57,7 @@ class TerserUtilTest extends BaseR4Test { } @Test - void testCloneWithNonPrimitves() { + void testCloneWithNonPrimitives() { Patient p1 = new Patient(); Patient p2 = new Patient(); diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/ConfigLoader.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/ConfigLoader.java new file mode 100644 index 00000000000..4ff618ad65d --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/ConfigLoader.java @@ -0,0 +1,64 @@ +package ca.uhn.fhir.rest.server.interceptor; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.util.ClasspathUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.StringReader; +import java.util.Properties; + +public class ConfigLoader { + + private static final Logger ourLog = LoggerFactory.getLogger(ConfigLoader.class); + public static final String CLASSPATH = "classpath:"; + + public static String loadResourceContent(String theResourcePath) { + if(theResourcePath.startsWith(CLASSPATH)) { + theResourcePath = theResourcePath.substring(CLASSPATH.length()); + } + return ClasspathUtil.loadResource(theResourcePath); + } + + public static Properties loadProperties(String theResourcePath) { + String propsString = loadResourceContent(theResourcePath); + Properties props = new Properties(); + try { + props.load(new StringReader(propsString)); + } catch (IOException e) { + throw new RuntimeException(String.format("Unable to load properties at %s", theResourcePath), e); + } + return props; + } + + public static T loadJson(String theResourcePath, Class theModelClass) { + ObjectMapper mapper = new ObjectMapper(); + try { + return mapper.readValue(loadResourceContent(theResourcePath), theModelClass); + } catch (Exception e) { + throw new RuntimeException(String.format("Unable to parse resource at %s", theResourcePath), e); + } + } + +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/StandardizingInterceptor.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/StandardizingInterceptor.java new file mode 100644 index 00000000000..ba688e789d8 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/StandardizingInterceptor.java @@ -0,0 +1,190 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.fhirpath.FhirPathExecutionException; +import ca.uhn.fhir.fhirpath.IFhirPath; +import ca.uhn.fhir.interceptor.api.Hook; +import ca.uhn.fhir.interceptor.api.Interceptor; +import ca.uhn.fhir.interceptor.api.Pointcut; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.server.interceptor.ConfigLoader; +import ca.uhn.fhir.rest.server.interceptor.s13n.standardizers.EmailStandardizer; +import ca.uhn.fhir.rest.server.interceptor.s13n.standardizers.FirstNameStandardizer; +import ca.uhn.fhir.rest.server.interceptor.s13n.standardizers.IStandardizer; +import ca.uhn.fhir.rest.server.interceptor.s13n.standardizers.LastNameStandardizer; +import ca.uhn.fhir.rest.server.interceptor.s13n.standardizers.PhoneStandardizer; +import ca.uhn.fhir.rest.server.interceptor.s13n.standardizers.TextStandardizer; +import ca.uhn.fhir.rest.server.interceptor.s13n.standardizers.TitleStandardizer; +import org.hl7.fhir.instance.model.api.IBase; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IPrimitiveType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Interceptor +public class StandardizingInterceptor { + + /** + * Pre-defined standardizers + */ + public enum StandardizationType { + NAME_FAMILY, NAME_GIVEN, EMAIL, TITLE, PHONE, TEXT; + } + + public static final String STANDARDIZATION_DISABLED_HEADER = "HAPI-Standardization-Disabled"; + + private static final Logger ourLog = LoggerFactory.getLogger(StandardizingInterceptor.class); + + private Map> myConfig; + private Map myStandardizers = new HashMap<>(); + + public StandardizingInterceptor() { + super(); + + ourLog.info("Starting StandardizingInterceptor {}", this); + + myConfig = ConfigLoader.loadJson("classpath:field-s13n-rules.json", Map.class); + initStandardizers(); + } + + public StandardizingInterceptor(Map> theConfig) { + super(); + myConfig = theConfig; + initStandardizers(); + } + + public void initStandardizers() { + myStandardizers.put(StandardizationType.NAME_FAMILY.name(), new LastNameStandardizer()); + myStandardizers.put(StandardizationType.NAME_GIVEN.name(), new FirstNameStandardizer()); + myStandardizers.put(StandardizationType.EMAIL.name(), new EmailStandardizer()); + myStandardizers.put(StandardizationType.TITLE.name(), new TitleStandardizer()); + myStandardizers.put(StandardizationType.PHONE.name(), new PhoneStandardizer()); + myStandardizers.put(StandardizationType.TEXT.name(), new TextStandardizer()); + + ourLog.info("Initialized standardizers {}", myStandardizers); + } + + @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED) + public void resourcePreCreate(RequestDetails theRequest, IBaseResource theResource) { + ourLog.debug("Standardizing on pre-create for - {}, {}", theRequest, theResource); + standardize(theRequest, theResource); + } + + @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED) + public void resourcePreUpdate(RequestDetails theRequest, IBaseResource theOldResource, IBaseResource theNewResource) { + ourLog.debug("Standardizing on pre-update for - {}, {}, {}", theRequest, theOldResource, theNewResource); + standardize(theRequest, theNewResource); + } + + private void standardize(RequestDetails theRequest, IBaseResource theResource) { + if (!theRequest.getHeaders(STANDARDIZATION_DISABLED_HEADER).isEmpty()) { + ourLog.debug("Standardization for {} is disabled via header {}", theResource, STANDARDIZATION_DISABLED_HEADER); + return; + } + + if (theResource == null) { + ourLog.debug("Nothing to standardize for {}", theRequest); + return; + } + + FhirContext ctx = theRequest.getFhirContext(); + + String resourceType = ctx.getResourceType(theResource); + IFhirPath fhirPath = ctx.newFhirPath(); + + for (Map.Entry> rule : myConfig.entrySet()) { + String resourceFromConfig = rule.getKey(); + if (!appliesToResource(resourceFromConfig, resourceType)) { + continue; + } + + standardize(theResource, rule.getValue(), fhirPath); + } + } + + private void standardize(IBaseResource theResource, Map theRules, IFhirPath theFhirPath) { + for (Map.Entry rule : theRules.entrySet()) { + IStandardizer std = getStandardizer(rule); + List values; + try { + values = theFhirPath.evaluate(theResource, rule.getKey(), IBase.class); + } catch (FhirPathExecutionException e) { + ourLog.warn("Unable to evaluate path at {} for {}", rule.getKey(), theResource); + return; + } + + for (IBase v : values) { + if (!(v instanceof IPrimitiveType)) { + ourLog.warn("Value at path {} is of type {}, which is not of primitive type - skipping", rule.getKey(), v.fhirType()); + continue; + } + IPrimitiveType value = (IPrimitiveType) v; + String valueString = value.getValueAsString(); + String standardizedValueString = std.standardize(valueString); + value.setValueAsString(standardizedValueString); + ourLog.debug("Standardized {} to {}", valueString, standardizedValueString); + } + } + } + + private IStandardizer getStandardizer(Map.Entry rule) { + String standardizerName = rule.getValue(); + if (myStandardizers.containsKey(standardizerName)) { + return myStandardizers.get(standardizerName); + } + + IStandardizer standardizer; + try { + standardizer = (IStandardizer) Class.forName(standardizerName).getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new RuntimeException(String.format("Unable to create standardizer %s", standardizerName), e); + } + + myStandardizers.put(standardizerName, standardizer); + return standardizer; + } + + private boolean appliesToResource(String theResourceFromConfig, String theActualResourceType) { + return theResourceFromConfig.equals(theActualResourceType); + } + + public Map> getConfig() { + return myConfig; + } + + public void setConfig(Map> theConfig) { + myConfig = theConfig; + } + + public Map getStandardizers() { + return myStandardizers; + } + + public void setStandardizers(Map theStandardizers) { + myStandardizers = theStandardizers; + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/EmailStandardizer.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/EmailStandardizer.java new file mode 100644 index 00000000000..72c3161b089 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/EmailStandardizer.java @@ -0,0 +1,45 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +/** + * Standardizes email addresses by removing whitespace, ISO control characters and applying lower-case to the values. + */ +public class EmailStandardizer implements IStandardizer { + + @Override + public String standardize(String theString) { + StringBuilder buf = new StringBuilder(); + for (int offset = 0; offset < theString.length(); ) { + int codePoint = theString.codePointAt(offset); + offset += Character.charCount(codePoint); + + if (Character.isISOControl(codePoint)) { + continue; + } + + if (!Character.isWhitespace(codePoint)) { + buf.append(new String(Character.toChars(codePoint)).toLowerCase()); + } + } + return buf.toString(); + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/FirstNameStandardizer.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/FirstNameStandardizer.java new file mode 100644 index 00000000000..bd7d2ce6db2 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/FirstNameStandardizer.java @@ -0,0 +1,147 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.CaseUtils; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Standardizes first name by capitalizing all characters following a separators (e.g. -, ') and removing noise characters. + */ +public class FirstNameStandardizer extends TextStandardizer { + + private Set myDelimiters = new HashSet<>(); + + public FirstNameStandardizer() { + super(); + + initializeDelimiters(); + } + + protected void initializeDelimiters() { + addDelimiters("-", "'"); + } + + protected FirstNameStandardizer addDelimiters(String... theDelimiters) { + myDelimiters.addAll(Arrays.asList(theDelimiters)); + return this; + } + + public String standardize(String theString) { + theString = replaceTranslates(theString); + + return Arrays.stream(theString.split("\\s+")) + .map(this::standardizeNameToken) + .filter(s -> !StringUtils.isEmpty(s)) + .collect(Collectors.joining(" ")); + } + + protected String capitalize(String theString) { + if (theString.length() == 0) { + return theString; + } + if (theString.length() == 1) { + return theString.toUpperCase(); + } + + StringBuilder buf = new StringBuilder(theString.length()); + buf.append(Character.toUpperCase(theString.charAt(0))); + buf.append(theString.substring(1)); + return buf.toString(); + } + + protected String standardizeNameToken(String theToken) { + if (theToken.isEmpty()) { + return theToken; + } + + boolean isDelimitedToken = false; + for (String d : myDelimiters) { + if (theToken.contains(d)) { + isDelimitedToken = true; + theToken = standardizeDelimitedToken(theToken, d); + } + } + + if (isDelimitedToken) { + return theToken; + } + + theToken = removeNoise(theToken); + theToken = CaseUtils.toCamelCase(theToken, true); + return theToken; + } + + protected String standardizeDelimitedToken(String theToken, String d) { + boolean isTokenTheDelimiter = theToken.equals(d); + if (isTokenTheDelimiter) { + return theToken; + } + + String splitToken = checkForRegexp(d); + String[] splits = theToken.split(splitToken); + for (int i = 0; i < splits.length; i++) { + splits[i] = standardizeNameToken(splits[i]); + } + + String retVal = join(splits, d); + if (theToken.startsWith(d)) { + retVal = d.concat(retVal); + } + if (theToken.endsWith(d)) { + retVal = retVal.concat(d); + } + return retVal; + } + + protected String join(String[] theSplits, String theDelimiter) { + StringBuilder buf = new StringBuilder(); + for (int i = 0; i < theSplits.length; i++) { + String s = theSplits[i]; + if (s == null || s.isEmpty()) { + continue; + } + if (buf.length() != 0) { + buf.append(theDelimiter); + } + buf.append(s); + + } + return buf.toString(); + } + + protected String checkForRegexp(String theExpression) { + if (theExpression.equals(".") || theExpression.equals("|") + || theExpression.equals("(") || theExpression.equals(")")) { + return "\\".concat(theExpression); + } + return theExpression; + } + + protected boolean isDelimiter(String theString) { + return myDelimiters.contains(theString); + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/IStandardizer.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/IStandardizer.java new file mode 100644 index 00000000000..5483105af27 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/IStandardizer.java @@ -0,0 +1,36 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +/** + * Contract for standardizing textual primitives in the FHIR resources. + */ +public interface IStandardizer { + + /** + * Standardizes the specified string. + * + * @param theString String to be standardized + * @return Returns a standardized string. + */ + public String standardize(String theString); + +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/LastNameStandardizer.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/LastNameStandardizer.java new file mode 100644 index 00000000000..b8c5d463e7c --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/LastNameStandardizer.java @@ -0,0 +1,81 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import org.apache.commons.text.WordUtils; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * Standardizes last names by capitalizing all characters following a separators (e.g. -, '), capitalizing "Mac" and "Mc" + * prefixes and keeping name particles in lower case. + */ +public class LastNameStandardizer extends FirstNameStandardizer { + + private Set myParticles = new HashSet<>(Arrays.asList("van", "der", "ter", "de", "da", "la")); + private Set myPrefixes = new HashSet<>(Arrays.asList("mac", "mc")); + private Set myPrefixExcludes = new HashSet<>(Arrays.asList("machi")); + + public LastNameStandardizer() { + super(); + } + + protected LastNameStandardizer addDelimiters(String... theDelimiters) { + super.addDelimiters(theDelimiters); + return this; + } + + protected String standardizeNameToken(String theToken) { + if (theToken.isEmpty()) { + return theToken; + } + + if (myParticles.contains(theToken.toLowerCase())) { + return theToken.toLowerCase(); + } + + String retVal = super.standardizeNameToken(theToken); + return handlePrefix(retVal); + } + + protected String handlePrefix(String theToken) { + String lowerCaseToken = theToken.toLowerCase(); + for (String exclude : myPrefixExcludes) { + if (lowerCaseToken.startsWith(exclude)) { + return theToken; + } + } + + for (String prefix : myPrefixes) { + if (!lowerCaseToken.startsWith(prefix)) { + continue; + } + + String capitalizedPrefix = WordUtils.capitalize(prefix); + String capitalizedSuffix = WordUtils.capitalize(lowerCaseToken.replaceFirst(prefix, "")); + return capitalizedPrefix.concat(capitalizedSuffix); + } + return theToken; + } + +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/NoiseCharacters.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/NoiseCharacters.java new file mode 100644 index 00000000000..abde5838501 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/NoiseCharacters.java @@ -0,0 +1,119 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.rest.server.interceptor.ConfigLoader; + +import java.util.HashSet; +import java.util.Scanner; +import java.util.Set; + +public class NoiseCharacters { + + private static final int RANGE_THRESHOLD = 150; + + private Set myNoiseCharacters = new HashSet<>(); + private Set myNoiseCharacterRanges = new HashSet<>(); + + private int size; + + public int getSize() { + return myNoiseCharacters.size(); + } + + public void initializeFromClasspath() { + String noiseChars = ConfigLoader.loadResourceContent("classpath:noise-chars.txt"); + try (Scanner scanner = new Scanner(noiseChars)) { + while (scanner.hasNext()) { + parse(scanner.nextLine()); + } + } + } + + public boolean isNoise(int theChar) { + if (myNoiseCharacters.contains(theChar)) { + return true; + } + + for (Range r : myNoiseCharacterRanges) { + if (r.isInRange(theChar)) { + return true; + } + } + + return false; + } + + private void parse(String theString) { + if (theString.contains("-")) { + addRange(theString); + } else { + add(theString); + } + } + + public NoiseCharacters add(String theLiteral) { + myNoiseCharacters.add(toInt(theLiteral)); + return this; + } + + public NoiseCharacters addRange(String theRange) { + if (!theRange.contains("-")) { + throw new IllegalArgumentException(String.format("Invalid range %s", theRange)); + } + + String[] range = theRange.split("-"); + if (range.length < 2) { + throw new IllegalArgumentException(String.format("Invalid range %s", theRange)); + } + + addRange(range[0].trim(), range[1].trim()); + return this; + } + + public NoiseCharacters addRange(String theLowerBound, String theUpperBound) { + int lower = toInt(theLowerBound); + int upper = toInt(theUpperBound); + + if (lower > upper) { + throw new IllegalArgumentException(String.format("Invalid character range %s-%s", theLowerBound, theUpperBound)); + } + + if (upper - lower >= RANGE_THRESHOLD) { + myNoiseCharacterRanges.add(new Range(lower, upper)); + return this; + } + + for (int i = lower; i <= upper; i++) { + myNoiseCharacters.add(i); + } + return this; + } + + private int toInt(String theLiteral) { + if (!theLiteral.startsWith("#x")) { + throw new IllegalArgumentException("Unable to parse " + theLiteral); + } + + return Integer.parseInt(theLiteral.substring(2), 16); + } + +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/PhoneStandardizer.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/PhoneStandardizer.java new file mode 100644 index 00000000000..f4598ee1fad --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/PhoneStandardizer.java @@ -0,0 +1,42 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +/** + * Standardizes phone numbers to fit 123-456-7890 pattern. + */ +public class PhoneStandardizer implements IStandardizer { + + public static final String PHONE_NUMBER_PATTERN = "(\\d{3})(\\d{3})(\\d+)"; + public static final String PHONE_NUMBER_REPLACE_PATTERN = "$1-$2-$3"; + + @Override + public String standardize(String thePhone) { + StringBuilder buf = new StringBuilder(thePhone.length()); + for (char ch : thePhone.toCharArray()) { + if (Character.isDigit(ch)) { + buf.append(ch); + } + } + return buf.toString().replaceFirst(PHONE_NUMBER_PATTERN, PHONE_NUMBER_REPLACE_PATTERN); + } + +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/Range.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/Range.java new file mode 100644 index 00000000000..d44621bf267 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/Range.java @@ -0,0 +1,70 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import java.util.Objects; + +class Range { + + private int myStart; + private int myEnd; + + public Range(int theStart, int theEnd) { + this.myStart = theStart; + this.myEnd = theEnd; + } + + public boolean isInRange(int theNum) { + return theNum >= getStart() && theNum <= getEnd(); + } + + public int getStart() { + return myStart; + } + + public int getEnd() { + return myEnd; + } + + @Override + public boolean equals(Object theObject) { + if (this == theObject) { + return true; + } + + if (theObject == null || getClass() != theObject.getClass()) { + return false; + } + + Range range = (Range) theObject; + return myStart == range.myStart && myEnd == range.myEnd; + } + + @Override + public int hashCode() { + return Objects.hash(myStart, myEnd); + } + + @Override + public String toString() { + return String.format("[%s, %s]", getStart(), getEnd()); + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/TextStandardizer.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/TextStandardizer.java new file mode 100644 index 00000000000..610302d4a5a --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/TextStandardizer.java @@ -0,0 +1,166 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Standardizes text literals by removing noise characters. + */ +public class TextStandardizer implements IStandardizer { + + public static final Pattern DIACRITICAL_MARKS = Pattern.compile("\\p{InCombiningDiacriticalMarks}+"); + + public static final int EXT_ASCII_RANGE_START = 155; + public static final int EXT_ASCII_RANGE_END = 255; + + private List myAllowedExtendedAscii; + private Set myAllowedNonLetterAndDigitCharacters = new HashSet<>(); + private NoiseCharacters myNoiseCharacters = new NoiseCharacters(); + private Map myTranslates = new HashMap<>(); + + public TextStandardizer() { + myNoiseCharacters.initializeFromClasspath(); + + initializeAllowedNonLetterAndDigitCharacters(); + initializeTranslates(); + initializeAllowedExtendedAscii(); + } + + protected void initializeAllowedNonLetterAndDigitCharacters() { + addAllowedNonLetterAndDigitCharacters('.', '\'', ',', '-', '#', '/', '\\', ' '); + } + + protected TextStandardizer addAllowedNonLetterAndDigitCharacters(Character... theCharacters) { + myAllowedNonLetterAndDigitCharacters.addAll(asSet(theCharacters)); + return this; + } + + protected Set asSet(Character... theCharacters) { + return Arrays.stream(theCharacters) + .map(c -> (int) c) + .collect(Collectors.toSet()); + } + + protected TextStandardizer addTranslate(int theTranslate, char theMapping) { + myTranslates.put(theTranslate, theMapping); + return this; + } + + protected void initializeTranslates() { + addTranslate(0x0080, '\''); // PAD + addTranslate(0x00A0, ' '); //   + addTranslate((int) ' ', ' '); //   + addTranslate(0x201C, '"'); + addTranslate(0x201D, '"'); + addTranslate(0x2019, ' '); + addTranslate(0x2018, ' '); + addTranslate(0x02BD, ' '); + addTranslate(0x00B4, ' '); + addTranslate(0x02DD, '"'); + addTranslate((int) '–', '-'); + addTranslate((int) '-', '-'); + addTranslate((int) '~', '-'); + } + + protected void initializeAllowedExtendedAscii() { + myAllowedExtendedAscii = new ArrayList<>(); + + // refer to https://www.ascii-code.com for the codes + for (int[] i : new int[][]{{192, 214}, {216, 246}, {248, 255}}) { + addAllowedExtendedAsciiRange(i[0], i[1]); + } + } + + protected TextStandardizer addAllowedExtendedAsciiRange(int theRangeStart, int theRangeEnd) { + myAllowedExtendedAscii.add(new Range(theRangeStart, theRangeEnd)); + return this; + } + + public String standardize(String theString) { + theString = replaceTranslates(theString); + return removeNoise(theString); + } + + protected String replaceTranslates(String theString) { + StringBuilder buf = new StringBuilder(theString.length()); + for (char ch : theString.toCharArray()) { + if (myTranslates.containsKey((int) ch)) { + buf.append(myTranslates.get((int) ch)); + } else { + buf.append(ch); + } + } + return buf.toString(); + } + + protected String replaceAccents(String theString) { + String string = java.text.Normalizer.normalize(theString, java.text.Normalizer.Form.NFD); + return DIACRITICAL_MARKS.matcher(string).replaceAll(""); + } + + protected String removeNoise(String theToken) { + StringBuilder token = new StringBuilder(theToken.length()); + for (int offset = 0; offset < theToken.length(); ) { + int codePoint = theToken.codePointAt(offset); + offset += Character.charCount(codePoint); + + switch (Character.getType(codePoint)) { + case Character.CONTROL: // \p{Cc} + case Character.FORMAT: // \p{Cf} + case Character.PRIVATE_USE: // \p{Co} + case Character.SURROGATE: // \p{Cs} + case Character.UNASSIGNED: // \p{Cn} + break; + default: + if (!isNoiseCharacter(codePoint)) { + token.append(Character.toChars(codePoint)); + } + break; + } + } + return token.toString(); + } + + protected boolean isTranslate(int theChar) { + return myTranslates.containsKey(theChar); + } + + protected boolean isNoiseCharacter(int theChar) { + if (myAllowedExtendedAscii.stream().anyMatch(r -> r.isInRange(theChar))) { + return false; + } + boolean isExtendedAscii = (theChar >= EXT_ASCII_RANGE_START && theChar <= EXT_ASCII_RANGE_END); + if (isExtendedAscii) { + return true; + } + return myNoiseCharacters.isNoise(theChar); + } + +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/TitleStandardizer.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/TitleStandardizer.java new file mode 100644 index 00000000000..0403b365f17 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/TitleStandardizer.java @@ -0,0 +1,157 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * + */ +public class TitleStandardizer extends LastNameStandardizer { + + private Set myExceptions = new HashSet<>(Arrays.asList("EAS", "EPS", "LLC", "LLP", "of", "at", "in", "and")); + private Set myBiGramExceptions = new HashSet(); + + public TitleStandardizer() { + super(); + addDelimiters("/", ".", "|", ">", "<", "(", ")", ":", "!"); + addAllowed('(', ')', '@', ':', '!', '|', '>', '<'); + myBiGramExceptions.add(new String[] {"'", "s"}); + } + + private void addAllowed(char... theCharacter) { + for (char ch : theCharacter) { + addAllowedExtendedAsciiRange((int) ch, (int) ch); + addAllowedNonLetterAndDigitCharacters(ch); + } + } + + @Override + public String standardize(String theString) { + theString = replaceTranslates(theString); + + return Arrays.stream(theString.split("\\s+")) + .map(String::trim) + .map(this::standardizeText) + .filter(s -> !StringUtils.isEmpty(s)) + .map(this::checkTitleExceptions) + .collect(Collectors.joining(" ")); + } + + private List split(String theString) { + int cursor = 0; + int start = 0; + + List retVal = new ArrayList<>(); + StringBuilder buf = new StringBuilder(); + + while (cursor < theString.length()) { + int codePoint = theString.codePointAt(cursor); + cursor += Character.charCount(codePoint); + if (isNoiseCharacter(codePoint)) { + continue; + } + + String str = new String(Character.toChars(codePoint)); + if (isDelimiter(str)) { + if (buf.length() != 0) { + retVal.add(buf.toString()); + buf.setLength(0); + } + retVal.add(str); + continue; + } + + buf.append(str); + } + + if (buf.length() != 0) { + retVal.add(buf.toString()); + } + + return retVal; + } + + protected String standardizeText(String theToken) { + StringBuilder buf = new StringBuilder(); + List parts = split(theToken); + + String prevPart = null; + for(String part : parts) { + if (isAllText(part)) { + part = standardizeNameToken(part); + } + + part = checkBiGram(prevPart, part); + buf.append(part); + prevPart = part; + } + return buf.toString(); + } + + private String checkBiGram(String thePart0, String thePart1) { + for (String[] biGram : myBiGramExceptions) { + if (biGram[0].equalsIgnoreCase(thePart0) + && biGram[1].equalsIgnoreCase(thePart1)) { + return biGram[1]; + } + } + return thePart1; + } + + private boolean isAllText(String thePart) { + for (int offset = 0; offset < thePart.length(); ) { + int codePoint = thePart.codePointAt(offset); + if (!Character.isLetter(codePoint)) { + return false; + } + offset += Character.charCount(codePoint); + } + return true; + } + + @Override + protected String standardizeNameToken(String theToken) { + String exception = myExceptions.stream() + .filter(s -> s.equalsIgnoreCase(theToken)) + .findFirst() + .orElse(null); + if (exception != null) { + return exception; + } + + return super.standardizeNameToken(theToken); + } + + private String checkTitleExceptions(String theString) { + return myExceptions.stream() + .filter(s -> s.equalsIgnoreCase(theString)) + .findFirst() + .orElse(theString); + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidatingInterceptor.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidatingInterceptor.java new file mode 100644 index 00000000000..4851fc90c55 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidatingInterceptor.java @@ -0,0 +1,173 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.address; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.context.BaseRuntimeChildDefinition; +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.RuntimeResourceDefinition; +import ca.uhn.fhir.interceptor.api.Hook; +import ca.uhn.fhir.interceptor.api.Interceptor; +import ca.uhn.fhir.interceptor.api.Pointcut; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.server.interceptor.ConfigLoader; +import ca.uhn.fhir.util.ExtensionUtil; +import org.apache.commons.lang3.Validate; +import org.hl7.fhir.instance.model.api.IBase; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.stream.Collectors; + +@Interceptor +public class AddressValidatingInterceptor { + + private static final Logger ourLog = LoggerFactory.getLogger(AddressValidatingInterceptor.class); + + public static final String ADDRESS_TYPE_NAME = "Address"; + public static final String PROPERTY_VALIDATOR_CLASS = "validator.class"; + + public static final String ADDRESS_VALIDATION_DISABLED_HEADER = "HAPI-Address-Validation-Disabled"; + + private IAddressValidator myAddressValidator; + + private Properties myProperties; + + + public AddressValidatingInterceptor() { + super(); + + ourLog.info("Starting AddressValidatingInterceptor {}", this); + myProperties = ConfigLoader.loadProperties("classpath:address-validation.properties"); + start(myProperties); + } + + public AddressValidatingInterceptor(Properties theProperties) { + super(); + start(theProperties); + } + + public void start(Properties theProperties) { + if (!theProperties.containsKey(PROPERTY_VALIDATOR_CLASS)) { + ourLog.info("Address validator class is not defined. Validation is disabled"); + return; + } + + String validatorClassName = theProperties.getProperty(PROPERTY_VALIDATOR_CLASS); + Validate.notBlank(validatorClassName, "%s property can not be blank", PROPERTY_VALIDATOR_CLASS); + + ourLog.info("Using address validator {}", validatorClassName); + try { + Class validatorClass = Class.forName(validatorClassName); + IAddressValidator addressValidator; + try { + addressValidator = (IAddressValidator) validatorClass + .getDeclaredConstructor(Properties.class).newInstance(theProperties); + } catch (Exception e) { + addressValidator = (IAddressValidator) validatorClass.getDeclaredConstructor().newInstance(); + } + setAddressValidator(addressValidator); + } catch (Exception e) { + throw new RuntimeException("Unable to create validator", e); + } + } + + @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED) + public void resourcePreCreate(RequestDetails theRequest, IBaseResource theResource) { + ourLog.debug("Validating address on for create {}, {}", theResource, theRequest); + handleRequest(theRequest, theResource); + } + + @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED) + public void resourcePreUpdate(RequestDetails theRequest, IBaseResource theOldResource, IBaseResource theNewResource) { + ourLog.debug("Validating address on for update {}, {}, {}", theOldResource, theNewResource, theRequest); + handleRequest(theRequest, theNewResource); + } + + protected void handleRequest(RequestDetails theRequest, IBaseResource theResource) { + if (getAddressValidator() == null) { + return; + } + + if (!theRequest.getHeaders(ADDRESS_VALIDATION_DISABLED_HEADER).isEmpty()) { + ourLog.debug("Address validation is disabled for this request via header"); + } + + FhirContext ctx = theRequest.getFhirContext(); + getAddresses(theResource, ctx) + .stream() + .filter(a -> { + return !ExtensionUtil.hasExtension(a, IAddressValidator.ADDRESS_VALIDATION_EXTENSION_URL) || + ExtensionUtil.hasExtension(a, IAddressValidator.ADDRESS_VALIDATION_EXTENSION_URL, IAddressValidator.EXT_UNABLE_TO_VALIDATE); + }) + .forEach(a -> validateAddress(a, ctx)); + } + + protected void validateAddress(IBase theAddress, FhirContext theFhirContext) { + try { + AddressValidationResult validationResult = getAddressValidator().isValid(theAddress, theFhirContext); + ourLog.debug("Validated address {}", validationResult); + + ExtensionUtil.setExtension(theFhirContext, theAddress, IAddressValidator.ADDRESS_VALIDATION_EXTENSION_URL, + validationResult.isValid() ? IAddressValidator.EXT_VALUE_VALID : IAddressValidator.EXT_VALUE_INVALID); + } catch (Exception ex) { + ourLog.warn("Unable to validate address", ex); + ExtensionUtil.setExtension(theFhirContext, theAddress, IAddressValidator.ADDRESS_VALIDATION_EXTENSION_URL, IAddressValidator.EXT_UNABLE_TO_VALIDATE); + } + } + + protected List getAddresses(IBaseResource theResource, final FhirContext theFhirContext) { + RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theResource); + + List retVal = new ArrayList<>(); + for (BaseRuntimeChildDefinition c : definition.getChildren()) { + Class childClass = c.getClass(); + List allValues = c.getAccessor() + .getValues(theResource) + .stream() + .filter(v -> ADDRESS_TYPE_NAME.equals(v.getClass().getSimpleName())) + .collect(Collectors.toList()); + + retVal.addAll(allValues); + } + + return (List) retVal; + } + + public IAddressValidator getAddressValidator() { + return myAddressValidator; + } + + public void setAddressValidator(IAddressValidator theAddressValidator) { + this.myAddressValidator = theAddressValidator; + } + + public Properties getProperties() { + return myProperties; + } + + public void setProperties(Properties theProperties) { + myProperties = theProperties; + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidationException.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidationException.java new file mode 100644 index 00000000000..ad1308796da --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidationException.java @@ -0,0 +1,39 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.address; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +public class AddressValidationException extends RuntimeException { + + public AddressValidationException() {} + + public AddressValidationException(String theMessage) { + super(theMessage); + } + + public AddressValidationException(String theMessage, Throwable theCause) { + super(theMessage, theCause); + } + + public AddressValidationException(Throwable theCause) { + super(theCause); + } + +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidationResult.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidationResult.java new file mode 100644 index 00000000000..4d604a2ef5b --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidationResult.java @@ -0,0 +1,85 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.address; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import org.hl7.fhir.instance.model.api.IBase; + +import java.util.HashMap; +import java.util.Map; + +public class AddressValidationResult { + + private boolean myIsValid; + private String myValidatedAddressString; + private Map myValidationResults = new HashMap<>(); + private String myRawResponse; + private IBase myValidatedAddress; + + public boolean isValid() { + return myIsValid; + } + + public void setValid(boolean theIsValid) { + this.myIsValid = theIsValid; + } + + public Map getValidationResults() { + return myValidationResults; + } + + public void setValidationResults(Map myValidationResults) { + this.myValidationResults = myValidationResults; + } + + public String getValidatedAddressString() { + return myValidatedAddressString; + } + + public void setValidatedAddressString(String theValidatedAddressString) { + this.myValidatedAddressString = theValidatedAddressString; + } + + public IBase getValidatedAddress() { + return myValidatedAddress; + } + + public void setValidatedAddress(IBase theValidatedAddress) { + this.myValidatedAddress = theValidatedAddress; + } + + public String getRawResponse() { + return myRawResponse; + } + + public void setRawResponse(String theRawResponse) { + this.myRawResponse = theRawResponse; + } + + @Override + public String toString() { + return + " isValid=" + myIsValid + + ", validatedAddressString='" + myValidatedAddressString + '\'' + + ", validationResults=" + myValidationResults + '\'' + + ", rawResponse='" + myRawResponse + '\'' + + ", myValidatedAddress='" + myValidatedAddress + '\''; + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/IAddressValidator.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/IAddressValidator.java new file mode 100644 index 00000000000..4f6afcbe9f7 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/IAddressValidator.java @@ -0,0 +1,61 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.address; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.context.FhirContext; +import org.hl7.fhir.instance.model.api.IBase; + +/** + * Contract for validating addresses. + */ +public interface IAddressValidator { + + /** + * URL for validation results that should be placed on addresses + */ + public static final String ADDRESS_VALIDATION_EXTENSION_URL = "https://hapifhir.org/AddressValidation/"; + + /** + * Extension value confirming that address can be considered valid (it exists and can be traced to the building) + */ + public static final String EXT_VALUE_VALID = "valid"; + + /** + * Extension value confirming that address is invalid (doesn't exist) + */ + public static final String EXT_VALUE_INVALID = "invalid"; + + /** + * Extension value indicating that address validation was attempted but could not complete successfully + */ + public static final String EXT_UNABLE_TO_VALIDATE = "not-validated"; + + /** + * Validates address against a service + * + * @param theAddress Address to be validated + * @param theFhirContext Current FHIR context + * @return Returns true in case address represents a valid + * @throws AddressValidationException AddressValidationException is thrown in case validation can not be completed successfully. + */ + AddressValidationResult isValid(IBase theAddress, FhirContext theFhirContext) throws AddressValidationException; + +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/BaseRestfulValidator.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/BaseRestfulValidator.java new file mode 100644 index 00000000000..7336f439cfa --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/BaseRestfulValidator.java @@ -0,0 +1,100 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.address.impl; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.server.interceptor.validation.address.IAddressValidator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import ca.uhn.fhir.rest.server.interceptor.validation.address.AddressValidationException; +import ca.uhn.fhir.rest.server.interceptor.validation.address.AddressValidationResult; +import org.hl7.fhir.instance.model.api.IBase; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import java.util.Properties; + +public abstract class BaseRestfulValidator implements IAddressValidator { + + public static final String PROPERTY_SERVICE_KEY = "service.key"; + + private static final Logger ourLog = LoggerFactory.getLogger(BaseRestfulValidator.class); + + private Properties myProperties; + + protected abstract AddressValidationResult getValidationResult(AddressValidationResult theResult, JsonNode response, FhirContext theFhirContext) throws Exception; + + protected abstract ResponseEntity getResponseEntity(IBase theAddress, FhirContext theFhirContext) throws Exception; + + protected RestTemplate newTemplate() { + return new RestTemplate(); + } + + public BaseRestfulValidator(Properties theProperties) { + myProperties = theProperties; + } + + @Override + public AddressValidationResult isValid(IBase theAddress, FhirContext theFhirContext) throws AddressValidationException { + ResponseEntity entity; + try { + entity = getResponseEntity(theAddress, theFhirContext); + } catch (Exception e) { + throw new AddressValidationException("Unable to complete address validation web-service call", e); + } + + if (isError(entity)) { + throw new AddressValidationException(String.format("Service returned an error code %s", entity.getStatusCode())); + } + + String responseBody = entity.getBody(); + ourLog.debug("Validation service returned {}", responseBody); + + AddressValidationResult retVal = new AddressValidationResult(); + retVal.setRawResponse(responseBody); + + try { + JsonNode response = new ObjectMapper().readTree(responseBody); + ourLog.debug("Parsed address validator response {}", response); + return getValidationResult(retVal, response, theFhirContext); + } catch (Exception e) { + throw new AddressValidationException("Unable to validate the address", e); + } + } + + protected boolean isError(ResponseEntity entity) { + return entity.getStatusCode().isError(); + } + + public Properties getProperties() { + return myProperties; + } + + public void setProperties(Properties theProperties) { + myProperties = theProperties; + } + + protected String getApiKey() { + return getProperties().getProperty(PROPERTY_SERVICE_KEY); + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/LoquateAddressValidator.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/LoquateAddressValidator.java new file mode 100644 index 00000000000..6a4e4521542 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/LoquateAddressValidator.java @@ -0,0 +1,212 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.address.impl; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.server.interceptor.validation.address.AddressValidationException; +import ca.uhn.fhir.rest.server.interceptor.validation.address.AddressValidationResult; +import ca.uhn.fhir.rest.server.interceptor.validation.helpers.AddressHelper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.http.entity.ContentType; +import org.hl7.fhir.instance.model.api.IBase; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; + +import javax.annotation.Nullable; +import java.util.Properties; + +/** + * For more details regarind the API refer to + * + * https://www.loqate.com/resources/support/cleanse-api/international-batch-cleanse/ + * + */ +public class LoquateAddressValidator extends BaseRestfulValidator { + + private static final Logger ourLog = LoggerFactory.getLogger(LoquateAddressValidator.class); + + private static final String[] DUPLICATE_FIELDS_IN_ADDRESS_LINES = {"Locality", "AdministrativeArea", "PostalCode"}; + + private static final String DATA_CLEANSE_ENDPOINT = "https://api.addressy.com/Cleansing/International/Batch/v1.00/json4.ws"; + private static final int MAX_ADDRESS_LINES = 8; + + public LoquateAddressValidator(Properties theProperties) { + super(theProperties); + if (!theProperties.containsKey(PROPERTY_SERVICE_KEY)) { + throw new IllegalArgumentException(String.format("Missing service key defined as %s", PROPERTY_SERVICE_KEY)); + } + } + + @Override + protected AddressValidationResult getValidationResult(AddressValidationResult theResult, JsonNode response, FhirContext theFhirContext) { + if (!response.isArray() || response.size() < 1) { + throw new AddressValidationException("Invalid response - expected to get an array of validated addresses"); + } + + JsonNode firstMatch = response.get(0); + if (!firstMatch.has("Matches")) { + throw new AddressValidationException("Invalid response - matches are unavailable"); + } + + JsonNode matches = firstMatch.get("Matches"); + if (!matches.isArray()) { + throw new AddressValidationException("Invalid response - expected to get a validated match in the response"); + } + + JsonNode match = matches.get(0); + return toAddressValidationResult(theResult, match, theFhirContext); + } + + private AddressValidationResult toAddressValidationResult(AddressValidationResult theResult, JsonNode theMatch, FhirContext theFhirContext) { + theResult.setValid(isValid(theMatch)); + + ourLog.debug("Address validation flag {}", theResult.isValid()); + JsonNode addressNode = theMatch.get("Address"); + if (addressNode != null) { + theResult.setValidatedAddressString(addressNode.asText()); + } + + ourLog.debug("Validated address string {}", theResult.getValidatedAddressString()); + theResult.setValidatedAddress(toAddress(theMatch, theFhirContext)); + return theResult; + } + + protected boolean isValid(JsonNode theMatch) { + String addressQualityIndex = null; + if (theMatch.has("AQI")) { + addressQualityIndex = theMatch.get("AQI").asText(); + } + + ourLog.debug("Address quality index {}", addressQualityIndex); + return "A".equals(addressQualityIndex) || "B".equals(addressQualityIndex); + } + + protected IBase toAddress(JsonNode match, FhirContext theFhirContext) { + IBase addressBase = theFhirContext.getElementDefinition("Address").newInstance(); + + AddressHelper helper = new AddressHelper(theFhirContext, addressBase); + helper.setText(getString(match, "Address")); + + String str = getString(match, "Address1"); + if (str != null) { + helper.addLine(str); + } + + removeDuplicateAddressLines(match, helper); + + helper.setCity(getString(match, "Locality")); + helper.setState(getString(match, "AdministrativeArea")); + helper.setPostalCode(getString(match, "PostalCode")); + helper.setCountry(getString(match, "CountryName")); + + return helper.getAddress(); + } + + private void removeDuplicateAddressLines(JsonNode match, AddressHelper address) { + int lineCount = 1; + String addressLine = null; + while ((addressLine = getString(match, "Address" + ++lineCount)) != null) { + if (isDuplicate(addressLine, match)) { + continue; + } + address.addLine(addressLine); + } + } + + private boolean isDuplicate(String theAddressLine, JsonNode theMatch) { + for (String s : DUPLICATE_FIELDS_IN_ADDRESS_LINES) { + JsonNode node = theMatch.get(s); + if (node == null) { + continue; + } + theAddressLine = theAddressLine.replaceAll(node.asText(), ""); + } + return theAddressLine.trim().isEmpty(); + } + + @Nullable + private String getString(JsonNode theNode, String theField) { + if (!theNode.has(theField)) { + return null; + } + + JsonNode field = theNode.get(theField); + if (field.asText().isEmpty()) { + return null; + } + return theNode.get(theField).asText(); + } + + @Override + protected ResponseEntity getResponseEntity(IBase theAddress, FhirContext theFhirContext) throws Exception { + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.ACCEPT, ContentType.APPLICATION_JSON.getMimeType()); + headers.set(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.getMimeType()); + headers.set(HttpHeaders.USER_AGENT, "SmileCDR"); + + String requestBody = getRequestBody(theFhirContext, theAddress); + HttpEntity request = new HttpEntity<>(requestBody, headers); + return newTemplate().postForEntity(DATA_CLEANSE_ENDPOINT, request, String.class); + } + + protected String getRequestBody(FhirContext theFhirContext, IBase... theAddresses) throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode rootNode = mapper.createObjectNode(); + rootNode.put("Key", getApiKey()); + rootNode.put("Geocode", false); + + ArrayNode addressesArrayNode = mapper.createArrayNode(); + int i = 0; + for (IBase address : theAddresses) { + ourLog.debug("Converting {} out of {} addresses", i++, theAddresses.length); + ObjectNode addressNode = toJsonNode(address, mapper, theFhirContext); + addressesArrayNode.add(addressNode); + } + rootNode.set("Addresses", addressesArrayNode); + return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(rootNode); + } + + protected ObjectNode toJsonNode(IBase theAddress, ObjectMapper mapper, FhirContext theFhirContext) { + AddressHelper helper = new AddressHelper(theFhirContext, theAddress); + ObjectNode addressNode = mapper.createObjectNode(); + + int count = 1; + for (String s : helper.getMultiple("line")) { + addressNode.put("Address" + count, s); + count++; + + if (count > MAX_ADDRESS_LINES) { + break; + } + } + addressNode.put("Locality", helper.getCity()); + addressNode.put("PostalCode", helper.getPostalCode()); + addressNode.put("Country", helper.getCountry()); + return addressNode; + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/MelissaAddressValidator.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/MelissaAddressValidator.java new file mode 100644 index 00000000000..2a886487792 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/MelissaAddressValidator.java @@ -0,0 +1,139 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.address.impl; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.server.interceptor.validation.address.AddressValidationException; +import ca.uhn.fhir.rest.server.interceptor.validation.address.AddressValidationResult; +import ca.uhn.fhir.rest.server.interceptor.validation.helpers.AddressHelper; +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.commons.lang3.StringUtils; +import org.hl7.fhir.instance.model.api.IBase; +import org.springframework.http.ResponseEntity; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; + +public class MelissaAddressValidator extends BaseRestfulValidator { + + public static final String GLOBAL_ADDRESS_VALIDATION_ENDPOINT = "https://address.melissadata.net/v3/WEB/GlobalAddress/doGlobalAddress" + + "?id={id}&a1={a1}&a2={a2}&ctry={ctry}&format={format}"; + + public MelissaAddressValidator(Properties theProperties) { + super(theProperties); + } + + @Override + protected AddressValidationResult getValidationResult(AddressValidationResult theResult, JsonNode theResponse, FhirContext theFhirContext) { + Response response = new Response(theResponse); + theResult.setValid(response.isValidAddress()); + theResult.setValidatedAddressString(response.getAddress()); + return theResult; + } + + @Override + protected ResponseEntity getResponseEntity(IBase theAddress, FhirContext theFhirContext) throws Exception { + Map requestParams = getRequestParams(theAddress); + return newTemplate().getForEntity(GLOBAL_ADDRESS_VALIDATION_ENDPOINT, String.class, requestParams); + } + + protected Map getRequestParams(IBase theAddress) { + AddressHelper helper = new AddressHelper(null, theAddress); + + Map requestParams = new HashMap<>(); + requestParams.put("t", UUID.randomUUID().toString()); + requestParams.put("id", getApiKey()); + requestParams.put("a1", helper.getLine()); + requestParams.put("a2", helper.getParts()); + requestParams.put("ctry", helper.getCountry()); + requestParams.put("format", "json"); + return requestParams; + } + + private static class Response { + private JsonNode root; + private JsonNode records; + private JsonNode results; + + private List addressErrors = new ArrayList<>(); + private List addressChange = new ArrayList<>(); + private List geocodeStatus = new ArrayList<>(); + private List geocodeError = new ArrayList<>(); + private List addressVerification = new ArrayList<>(); + + public Response(JsonNode theRoot) { + root = theRoot; + + // see codes here - http://wiki.melissadata.com/index.php?title=Result_Codes + String transmissionResults = root.get("TransmissionResults").asText(); + if (!StringUtils.isEmpty(transmissionResults)) { + geocodeError.add(transmissionResults); + throw new AddressValidationException(String.format("Transmission result %s indicate an error with the request - please check API_KEY", transmissionResults)); + } + + int recordCount = root.get("TotalRecords").asInt(); + if (recordCount < 1) { + throw new AddressValidationException("Expected at least one record in the address validation response"); + } + + // get first match + records = root.get("Records").get(0); + results = records.get("Results"); + + // full list of response codes is available here + // http://wiki.melissadata.com/index.php?title=Result_Code_Details#Global_Address_Verification + for (String s : results.asText().split(",")) { + if (s.startsWith("AE")) { + addressErrors.add(s); + } else if (s.startsWith("AC")) { + addressChange.add(s); + } else if (s.startsWith("GS")) { + geocodeStatus.add(s); + } else if (s.startsWith("GE")) { + geocodeError.add(s); + } else if (s.startsWith("AV")) { + addressVerification.add(s); + } + } + } + + public boolean isValidAddress() { + if (!geocodeError.isEmpty()) { + return false; + } + return addressErrors.isEmpty() && (geocodeStatus.contains("GS05") || geocodeStatus.contains("GS06")); + } + + public String getAddress() { + if (records == null) { + return ""; + } + if (!records.has("FormattedAddress")) { + return ""; + } + return records.get("FormattedAddress").asText(""); + } + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/EmailValidator.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/EmailValidator.java new file mode 100644 index 00000000000..cecc86457da --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/EmailValidator.java @@ -0,0 +1,34 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.fields; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import java.util.regex.Pattern; + +public class EmailValidator implements IValidator { + + private Pattern myEmailPattern = Pattern.compile("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", + Pattern.CASE_INSENSITIVE); + + @Override + public boolean isValid(String theString) { + return myEmailPattern.matcher(theString).matches(); + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/FieldValidatingInterceptor.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/FieldValidatingInterceptor.java new file mode 100644 index 00000000000..36394169521 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/FieldValidatingInterceptor.java @@ -0,0 +1,115 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.fields; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.fhirpath.IFhirPath; +import ca.uhn.fhir.interceptor.api.Hook; +import ca.uhn.fhir.interceptor.api.Interceptor; +import ca.uhn.fhir.interceptor.api.Pointcut; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.server.interceptor.ConfigLoader; +import ca.uhn.fhir.rest.server.interceptor.ServerOperationInterceptorAdapter; +import ca.uhn.fhir.rest.server.interceptor.validation.address.IAddressValidator; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IPrimitiveType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; + +@Interceptor +public class FieldValidatingInterceptor { + + public enum ValidatorType { + EMAIL; + } + + private static final Logger ourLog = LoggerFactory.getLogger(FieldValidatingInterceptor.class); + + public static final String VALIDATION_DISABLED_HEADER = "HAPI-Field-Validation-Disabled"; + + private IAddressValidator myAddressValidator; + + private Map myConfig; + + + public FieldValidatingInterceptor() { + super(); + + ourLog.info("Starting FieldValidatingInterceptor {}", this); + myConfig = ConfigLoader.loadJson("classpath:field-validation-rules.json", Map.class); + } + + @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED) + public void resourcePreCreate(RequestDetails theRequest, IBaseResource theResource) { + ourLog.debug("Validating address on create for resource {} / request {}", theResource, theRequest); + handleRequest(theRequest, theResource); + } + + @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED) + public void resourcePreUpdate(RequestDetails theRequest, IBaseResource theOldResource, IBaseResource theNewResource) { + ourLog.debug("Validating address on update for resource {} / old resource {} / request {}", theOldResource, theNewResource, theRequest); + handleRequest(theRequest, theNewResource); + } + + protected void handleRequest(RequestDetails theRequest, IBaseResource theResource) { + if (!theRequest.getHeaders(VALIDATION_DISABLED_HEADER).isEmpty()) { + ourLog.debug("Address validation is disabled for this request via header"); + return; + } + + FhirContext ctx = theRequest.getFhirContext(); + IFhirPath fhirPath = ctx.newFhirPath(); + for (Map.Entry e : myConfig.entrySet()) { + IValidator validator = getValidator(e.getValue()); + + List values = fhirPath.evaluate(theResource, e.getKey(), IPrimitiveType.class); + for (IPrimitiveType value : values) { + String valueAsString = value.getValueAsString(); + if (!validator.isValid(valueAsString)) { + throw new IllegalArgumentException(String.format("Invalid resource %s", valueAsString)); + } + } + } + } + + private IValidator getValidator(String theValue) { + if (ValidatorType.EMAIL.name().equals(theValue)) { + return new EmailValidator(); + } + + try { + return (IValidator) Class.forName(theValue).getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new IllegalStateException(String.format("Unable to create validator for %s", theValue), e); + } + } + + public Map getConfig() { + return myConfig; + } + + public void setConfig(Map theConfig) { + myConfig = theConfig; + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/IValidator.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/IValidator.java new file mode 100644 index 00000000000..4b2c0a98fc8 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/IValidator.java @@ -0,0 +1,27 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.fields; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +public interface IValidator { + + public boolean isValid(String theString); + +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/helpers/AddressHelper.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/helpers/AddressHelper.java new file mode 100644 index 00000000000..926ffe3f209 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/helpers/AddressHelper.java @@ -0,0 +1,123 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.helpers; + +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.util.PropertyModifyingHelper; +import org.apache.commons.lang3.StringUtils; +import org.hl7.fhir.instance.model.api.IBase; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Helper class for working with FHIR Address element + */ +public class AddressHelper extends PropertyModifyingHelper { + + public static final String FIELD_LINE = "line"; + public static final String FIELD_CITY = "city"; + public static final String FIELD_TEXT = "text"; + public static final String FIELD_DISTRICT = "district"; + public static final String FIELD_STATE = "state"; + public static final String FIELD_POSTAL = "postalCode"; + public static final String FIELD_COUNTRY = "country"; + + public static final String[] FIELD_NAMES = {FIELD_TEXT, FIELD_LINE, FIELD_CITY, FIELD_DISTRICT, FIELD_STATE, + FIELD_POSTAL, FIELD_COUNTRY}; + + public static final String[] ADDRESS_PARTS = {FIELD_CITY, FIELD_DISTRICT, FIELD_STATE, FIELD_POSTAL}; + + public AddressHelper(FhirContext theFhirContext, IBase theBase) { + super(theFhirContext, theBase); + } + + public String getCountry() { + return get(FIELD_COUNTRY); + } + + public String getCity() { + return get(FIELD_CITY); + } + + public String getState() { + return get(FIELD_STATE); + } + + public String getPostalCode() { + return get(FIELD_POSTAL); + } + + public String getText() { + return get(FIELD_TEXT); + } + + public void setCountry(String theCountry) { + set(FIELD_COUNTRY, theCountry); + } + + public void setCity(String theCity) { + set(FIELD_CITY, theCity); + } + + public void setState(String theState) { + set(FIELD_STATE, theState); + } + + public void setPostalCode(String thePostal) { + set(FIELD_POSTAL, thePostal); + } + + public void setText(String theText) { + set(FIELD_TEXT, theText); + } + + public String getParts() { + return Arrays.stream(ADDRESS_PARTS) + .map(this::get) + .filter(s -> !StringUtils.isBlank(s)) + .collect(Collectors.joining(getDelimiter())); + } + + public String getLine() { + return get(FIELD_LINE); + } + + public List getLines() { + return getMultiple(FIELD_LINE); + } + + public AddressHelper addLine(String theLine) { + set(FIELD_LINE, theLine); + return this; + } + + public T getAddress() { + return (T) getBase(); + } + + @Override + public String toString() { + return getFields(FIELD_NAMES); + } + +} diff --git a/hapi-fhir-server/src/main/resources/address-validation.properties b/hapi-fhir-server/src/main/resources/address-validation.properties new file mode 100644 index 00000000000..73f9700a9ce --- /dev/null +++ b/hapi-fhir-server/src/main/resources/address-validation.properties @@ -0,0 +1,12 @@ +# +# Validator class indicates validator that should be used by the AddressValidatingInterceptor for validating all +# inbound addresses. This class should implement ca.uhn.fhir.rest.server.interceptor.validation.address.IAddressValidator +# interface and provide either a default constructor or constructor accepting java.util.Properties. In the later case +# this properties will be passed to that constructor. +# +# Delivered validators include ca.uhn.fhir.rest.server.interceptor.validation.address.impl.LoquateAddressValidator +# and ca.uhn.fhir.rest.server.interceptor.validation.address.impl.MelissaAddressValidator. Both require service +# key passed as service.key property. +# +validator.class= +service.key= diff --git a/hapi-fhir-server/src/main/resources/field-s13n-rules.json b/hapi-fhir-server/src/main/resources/field-s13n-rules.json new file mode 100644 index 00000000000..69c1e4eeceb --- /dev/null +++ b/hapi-fhir-server/src/main/resources/field-s13n-rules.json @@ -0,0 +1,15 @@ +{ + "Person" : { + "Person.name.family" : "NAME_FAMILY", + "Person.name.given" : "NAME_GIVEN", + "Person.telecom.where(system='phone').value" : "PHONE" + }, + "Patient" : { + "name.family" : "NAME_FAMILY", + "name.given" : "NAME_GIVEN", + "telecom.where(system='phone').value" : "PHONE" + }, + "*" : { + "telecom.where(system='email').value" : "EMAIL" + } +} diff --git a/hapi-fhir-server/src/main/resources/field-validation-rules.json b/hapi-fhir-server/src/main/resources/field-validation-rules.json new file mode 100644 index 00000000000..edb9a463089 --- /dev/null +++ b/hapi-fhir-server/src/main/resources/field-validation-rules.json @@ -0,0 +1,3 @@ +{ + "telecom.where(system='email').value" : "EMAIL" +} diff --git a/hapi-fhir-server/src/main/resources/noise-chars.txt b/hapi-fhir-server/src/main/resources/noise-chars.txt new file mode 100644 index 00000000000..769455e93b1 --- /dev/null +++ b/hapi-fhir-server/src/main/resources/noise-chars.txt @@ -0,0 +1,375 @@ +#x0000-#x0022 +#x0028-#x002A +#x003A-#x0040 +#x005B-#x005D +#x005F-#x0060 +#x007B-#x00A7 +#x00A9 +#x00AB-#x00AE +#x00B0-#x00B1 +#x00B6-#x00B7 +#x00BB +#x00BF +#x00D7 +#x00F7 +#x0237-#x024F +#x0358-#x035C +#x0370-#x0373 +#x0376-#x0379 +#x037B-#x0383 +#x0387 +#x038B +#x038D +#x03A2 +#x03CF +#x03F6 +#x03FC-#x03FF +#x0482 +#x0487 +#x04CF +#x04F6-#x04F7 +#x04FA-#x04FF +#x0510-#x0530 +#x0557-#x0558 +#x055A-#x0560 +#x0588-#x0590 +#x05A2 +#x05BA +#x102B +#x1033-#x1035 +#x3097-#x3098 +#x103A-#x103F +#x104A-#x104F +#x30A0 +#x30FB +#x105A-#x109F +#x3100-#x3104 +#x10C6-#x10CF +#x312D-#x3130 +#x10F9-#x10FF +#x318F-#x3191 +#x115A-#x115E +#x11A3-#x11A7 +#x3196-#x319F +#x11FA-#x11FF +#x31B8-#x31EF +#x1207 +#x3200-#x321F +#x1247 +#x322A-#x3250 +#x1249 +#x3260-#x327F +#x124E-#x124F +#x1257 +#x328A-#x32B0 +#x1259 +#x32C0-#x33FF +#x125E-#x125F +#x4DB6-#x4DFF +#x1287 +#x9FA6-#x9FFF +#x1289 +#x128E-#x128F +#xA48D-#xABFF +#x12AF +#xD7A4-#xF8FF +#x12B1 +#xFA2E-#xFA2F +#x12B6-#x12B7 +#xFA6B-#xFAFF +#x12BF +#x12C1 +#xFB07-#xFB12 +#x12C6-#x12C7 +#xFB18-#xFB1C +#x12CF +#xFB29 +#x12D7 +#xFB37 +#x12EF +#xFB3D +#x130F +#xFB3F +#x1311 +#xFB42 +#x1316-#x1317 +#xFB45 +#x131F +#xFBB2-#xFBD2 +#x1347 +#xFD3E-#xFD4F +#x135B-#x1368 +#xFD90-#xFD91 +#x137D-#x139F +#xFDC8-#xFDEF +#x13F5-#x1400 +#xFDFC-#xFDFF +#x166D-#x166E +#xFE10-#xFE1F +#x1677-#x1680 +#xFE24-#xFE6F +#x169B-#x169F +#xFE75 +#x16EB-#x16FF +#xFEFD-#xFEFE +#x170D +#xFF00-#xFF0F +#x1715-#x171F +#xFF1A-#xFF20 +#x1735-#x173F +#xFF3B-#xFF3D +#x1754-#x175F +#xFF3F +#x176D +#x1771 +#xFF5B-#xFF65 +#x1774-#x177F +#xFFBF-#xFFC1 +#x17D4-#x17D6 +#xFFC8-#xFFC9 +#x17D8-#x17DB +#xFFD0-#xFFD1 +#x17DE-#x17DF +#xFFD8-#xFFD9 +#x17EA-#x17EF +#xFFDD-#xFFE2 +#x17FA-#x180A +#xFFE4-#xFFF8 +#x180E-#x180F +#xFFFC-#xFFFF +#x1000C +#x181A-#x181F +#x10027 +#x1878-#x187F +#x1003B +#x18AA-#x18FF +#x1003E +#x191D-#x191F +#x1004E-#x1004F +#x192C-#x192F +#x1005E-#x1007F +#x193C-#x1945 +#x100FB-#x10106 +#x196E-#x196F +#x10134-#x102FF +#x1031F +#x1975-#x1CFF +#x1D6C-#x1DFF +#x1E9C-#x1E9F +#x1EFA-#x1EFF +#x1F16-#x1F17 +#x1F1E-#x1F1F +#x1F46-#x1F47 +#x1F4E-#x1F4F +#x1F58 +#x1F5A +#x1F5C +#x1F5E +#x1F7E-#x1F7F +#x1FB5 +#x1FC5 +#x1FD4-#x1FD5 +#x1FDC +#x1FF0-#x1FF1 +#x1FF5 +#x1FFF-#x200B +#x2010-#x2029 +#x202F-#x205F +#x2064-#x2069 +#x2072-#x2073 +#x207A-#x207E +#x208A-#x20CF +#x20EB-#x2101 +#x2103-#x2106 +#x2108-#x2109 +#x2114 +#x2116-#x2118 +#x211E-#x2123 +#x2125 +#x2127 +#x2129 +#x212E +#x2132 +#x213A-#x213C +#x0ACE-#x0ACF +#x0AD1-#x0ADF +#x0AE4-#x0AE5 +#x0AF0-#x0B00 +#x0B04 +#x0B0D-#x0B0E +#x0B11-#x0B12 +#x0B29 +#x0B31 +#x0B34 +#x0B3A-#x0B3B +#x0B44-#x0B46 +#x0B49-#x0B4A +#x0B4E-#x0B55 +#x0B58-#x0B5B +#x0B5E +#x0B62-#x0B65 +#x0B70 +#x0B72-#x0B81 +#x0B84 +#x0B8B-#x0B8D +#x0B91 +#x0B96-#x0B98 +#x0B9B +#x0B9D +#x0BA0-#x0BA2 +#x0BA5-#x0BA7 +#x0BAB-#x0BAD +#x0BB6 +#x0BBA-#x0BBD +#x0BC3-#x0BC5 +#x0BC9 +#x0BCE-#x0BD6 +#x0BD8-#x0BE6 +#x0BF3-#x0C00 +#x0C04 +#x0C0D +#x0C11 +#x0C29 +#x0C34 +#x0C3A-#x0C3D +#x0C45 +#x0C49 +#x0C4E-#x0C54 +#x0C57-#x0C5F +#x0C62-#x0C65 +#x0C70-#x0C81 +#x0C84 +#x0C8D +#x0C91 +#x0CA9 +#x0CB4 +#x0CBA-#x0CBB +#x0CC5 +#x0CC9 +#x0CCE-#x0CD4 +#x0CD7-#x0CDD +#x0CDF +#x0CE2-#x0CE5 +#x0CF0-#x0D01 +#x0D04 +#x0D0D +#x0D11 +#x0D29 +#x0D3A-#x0D3D +#x0D44-#x0D45 +#x0D49 +#x0D4E-#x0D56 +#x0D58-#x0D5F +#x0D62-#x0D65 +#x0D70-#x0D81 +#x0D84 +#x0D97-#x0D99 +#x0DB2 +#x10324-#x1032F +#x1034A-#x1037F +#x1039E-#x103FF +#x1049E-#x1049F +#x104AA-#x107FF +#x10806-#x10807 +#x10809 +#x10836 +#x10839-#x1083B +#x1083D-#x1083E +#x10840-#x1D164 +#x1D16A-#x1D16C +#x1D183-#x1D184 +#x1D18C-#x1D1A9 +#x1D1AE-#x1D3FF +#x1D455 +#x1D49D +#x1D4A0-#x1D4A1 +#x1D4A3-#x1D4A4 +#x1D4A7-#x1D4A8 +#x1D4AD +#x1D4BA +#x1D4BC +#x1D4C4 +#x1D506 +#x1D50B-#x1D50C +#x1D515 +#x1D51D +#x1D53A +#x1D53F +#x1D545 +#x1D547-#x1D549 +#x1D551 +#x1D6A4-#x1D6A7 +#x1D6C1 +#x1D6DB +#x1D6FB +#x1D715 +#x1D735 +#x1D74F +#x1D76F +#x1D789 +#x0DBC +#x0DBE-#x0DBF +#x0DC7-#x0DC9 +#x0DCB-#x0DCE +#x0DD5 +#x0DD7 +#x0DE0-#x0DF1 +#x0DF4-#x0E00 +#x0E3B-#x0E3F +#x0E4F +#x0E5A-#x0E80 +#x0E83 +#x0E85-#x0E86 +#x0E89 +#x0E8B-#x0E8C +#x0E8E-#x0E93 +#x0E98 +#x0EA0 +#x0EA4 +#x0EA6 +#x0EA8-#x0EA9 +#x0EAC +#x0EBA +#x0EBE-#x0EBF +#x0EC5 +#x0EC7 +#x0ECE-#x0ECF +#x0EDA-#x0EDB +#x0EDE-#x0EFF +#x0F01-#x0F17 +#x0F1A-#x0F1F +#x0F34 +#x0F36 +#x0F38 +#x1D7A9 +#x1D7C3 +#x1D7CA-#x1D7CD +#x1D800-#x1FFFF +#x2A6D7-#x2F7FF +#x2FA1E-#xE0000 +#xE0002-#xE001F +#xE0080-#xE00FF +#x00B4 +#x0650 +#x0F3A-#x0F3D +#x0F48 +#x0F6B-#x0F70 +#x0F85 +#x0F8C-#x0F8F +#x0F98 +#x0FBD-#x0FC5 +#x0FC7-#x0FFF +#x1022 +#x1028 +#x2140-#x2144 +#x214A-#x2152 +#x2160-#x245F +#x249C-#x24E9 +#x2500-#x2775 +#x2794-#x3004 +#x3007-#x3029 +#x3030 +#x3036-#x303A +#x303D-#x3040 +#x0024-#x0025 +#x002C diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/ConfigLoaderTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/ConfigLoaderTest.java new file mode 100644 index 00000000000..fe377df7773 --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/ConfigLoaderTest.java @@ -0,0 +1,33 @@ +package ca.uhn.fhir.rest.server.interceptor; + +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.*; + +class ConfigLoaderTest { + + @Test + public void testConfigLoading() { + Map config = ConfigLoader.loadJson("classpath:field-s13n-rules.json", Map.class); + assertNotNull(config); + assertTrue(config.size() > 0); + + Properties props = ConfigLoader.loadProperties("classpath:address-validation.properties"); + assertNotNull(props); + assertTrue(props.size() > 0); + + String text = ConfigLoader.loadResourceContent("classpath:noise-chars.txt"); + assertNotNull(text); + assertTrue(text.length() > 0); + + try { + ConfigLoader.loadResourceContent("blah"); + fail(); + } catch (Exception e) { + } + } + +} diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/EmailStandardizerTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/EmailStandardizerTest.java new file mode 100644 index 00000000000..09aad415ede --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/EmailStandardizerTest.java @@ -0,0 +1,17 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class EmailStandardizerTest { + + @Test + public void testStandardization() { + IStandardizer std = new EmailStandardizer(); + assertEquals("thisis_afancy@email.com", std.standardize(" ThisIs_aFancy\n @email.com \t")); + assertEquals("емайл@мaйлсервер.ком", std.standardize("\t емайл@мAйлсервер.ком")); + assertEquals("show.me.the@moneycom", std.standardize("show . m e . t he@Moneycom")); + } + +} diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/EmailValidatorTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/EmailValidatorTest.java new file mode 100644 index 00000000000..78da20c5e6a --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/EmailValidatorTest.java @@ -0,0 +1,22 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +import ca.uhn.fhir.rest.server.interceptor.validation.fields.EmailValidator; +import ca.uhn.fhir.rest.server.interceptor.validation.fields.IValidator; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EmailValidatorTest { + + @Test + public void testEmailValidation() { + IValidator val = new EmailValidator(); + + assertTrue(val.isValid("show.me.the.money@email.com")); + assertFalse(val.isValid("money@email")); + assertFalse(val.isValid("show me the money@email.com")); + assertFalse(val.isValid("gimme dough")); + } + +} diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/NameStandardizerTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/NameStandardizerTest.java new file mode 100644 index 00000000000..2d717c7a699 --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/NameStandardizerTest.java @@ -0,0 +1,73 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class NameStandardizerTest { + + private LastNameStandardizer myLastNameStandardizer = new LastNameStandardizer(); + private FirstNameStandardizer myFirstNameStandardizer = new FirstNameStandardizer(); + + // for rules refer to https://docs.google.com/document/d/1Vz0vYwdDsqu6WrkRyzNiBJDLGmWAej5g/edit# + + @Test + public void testCleanNoiseCharacters() { + assertEquals("Public", myLastNameStandardizer.standardize("\u0070\u0075\u0062\u006c\u0069\u0063\u0020\u0020\u0020\u0020")); + assertEquals("This - Text Has /ã Lot # Of Special Characters", myLastNameStandardizer.standardize("\t\r\nThis - ┴\t┬\t├\t─\t┼ text ! has \\\\ /à lot # of % special % characters\n")); + assertEquals("Nbsp", myLastNameStandardizer.standardize("nbsp \u00A0")); + } + + @Test + public void testFrenchRemains() { + assertEquals("Ààââ Ææ", myLastNameStandardizer.standardize(" ÀàÂâ \tÆæ\n ")); + assertEquals("D D'Équateur", myLastNameStandardizer.standardize("D d'Équateur\n ")); + assertEquals("Des Idées L'Océan", myLastNameStandardizer.standardize("Des idées\nl'océan\n\n ")); + assertEquals("Ne T'Arrêtes Pas", myLastNameStandardizer.standardize("Ne\tt'arrêtes\npas\n ")); + } + + @Test + public void testMe() { + assertEquals("Tim", myFirstNameStandardizer.standardize("tim ☺ ")); + } + + @Test + public void testNameNormalization() { + assertEquals("Tim", myFirstNameStandardizer.standardize(" TIM ")); + assertEquals("Tim", myFirstNameStandardizer.standardize("tim ☺ ")); + assertEquals("Tim Berners-Lee", myLastNameStandardizer.standardize(" TiM BeRnErS-lEE\n")); + assertEquals("Sara O'Leary", myLastNameStandardizer.standardize("\t\nSAra o'leARy \n\n")); + assertEquals("Bill McMaster", myLastNameStandardizer.standardize("\nBILL MCMASTER \n\n")); + assertEquals("John MacMaster", myLastNameStandardizer.standardize("\njohn macmASTER \n\n")); + assertEquals("Vincent van Gogh", myLastNameStandardizer.standardize("vincent van gogh")); + assertEquals("Charles de Gaulle", myLastNameStandardizer.standardize("charles de gaulle\n")); + assertEquals("Charles-Gaspard de la Rive", myLastNameStandardizer.standardize("charles-gaspard de la rive")); + assertEquals("Niccolò Machiavelli", myLastNameStandardizer.standardize("niccolò machiavelli")); + assertEquals("O'Reilly M'Grego D'Angelo MacDonald McFry", myLastNameStandardizer.standardize("o'reilly m'grego d'angelo macdonald mcfry")); + assertEquals("Cornelius Vanderbilt", myLastNameStandardizer.standardize("cornelius vanderbilt")); + assertEquals("Cornelius Vanderbilt Jr.", myLastNameStandardizer.standardize("cornelius vanderbilt jr.")); + assertEquals("William Shakespeare", myLastNameStandardizer.standardize("william shakespeare")); + assertEquals("Mr. William Shakespeare", myLastNameStandardizer.standardize("mr. william shakespeare")); + assertEquals("Amber-Lynn O'Brien", myLastNameStandardizer.standardize("AMBER-LYNN O\u0080�BRIEN\n")); + assertEquals("Noelle Bethea", myLastNameStandardizer.standardize("NOELLE  BETHEA\n")); + assertEquals("Naomi Anne Ecob", myLastNameStandardizer.standardize("NAOMI ANNE  ECOB\n")); + assertEquals("Sarah Ann Mary Pollock", myLastNameStandardizer.standardize("SARAH ANN MARY POLLOCK\n")); + assertEquals("Tarit Kumar Kanungo", myLastNameStandardizer.standardize("TARIT KUMAR  KANUNGO\n")); + assertEquals("Tram Anh Thi Nguyen", myLastNameStandardizer.standardize("TRAM ANH THI  NGUYEN\n")); + assertEquals("William L. Trenwith / Paul J. Trenwith", myLastNameStandardizer.standardize("WILLIAM L. TRENWITH / PAUL J. TRENWITH\n")); + } + + @Test + public void testFirstNameNoPrefix() { + assertEquals("Mackenzie-Jonah", myFirstNameStandardizer.standardize("MACKENZIE-JONAH")); + assertEquals("Charles-Gaspard", myFirstNameStandardizer.standardize("CHARLES-Gaspard")); + } + + @Test + public void testTranslateMagic() { + assertEquals("O'Brien", myLastNameStandardizer.standardize("O\u0080�BRIEN\n")); + assertEquals("O ' Brien", myLastNameStandardizer.standardize("O \u0080� BRIEN\n")); + assertEquals("O 'Brien", myLastNameStandardizer.standardize("O \u0080�BRIEN\n")); + assertEquals("O' Brien", myLastNameStandardizer.standardize("O\u0080 BRIEN\n")); + } +} diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/NoiseCharactersTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/NoiseCharactersTest.java new file mode 100644 index 00000000000..8527dda73f1 --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/NoiseCharactersTest.java @@ -0,0 +1,70 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NoiseCharactersTest { + + private NoiseCharacters myFilter = new NoiseCharacters(); + + @Test + public void testInit() { + myFilter.initializeFromClasspath(); + assertTrue(myFilter.getSize() > 0); + + myFilter = new NoiseCharacters(); + } + + @Test + public void testAdd() { + myFilter.add("#x0487"); + + char check = (char) Integer.parseInt("487", 16); + assertTrue(myFilter.isNoise(check)); + assertFalse(myFilter.isNoise('A')); + } + + @Test + public void testAddRange() { + myFilter.addRange("#x0487-#x0489"); + + char check = (char) Integer.parseInt("487", 16); + assertTrue(myFilter.isNoise(check)); + check = (char) Integer.parseInt("488", 16); + assertTrue(myFilter.isNoise(check)); + check = (char) Integer.parseInt("489", 16); + assertTrue(myFilter.isNoise(check)); + + assertFalse(myFilter.isNoise('A')); + } + + @Test + public void testAddLongRange() { + myFilter.addRange("#x0487-#xA489"); + + char check = (char) Integer.parseInt("487", 16); + assertTrue(myFilter.isNoise(check)); + check = (char) Integer.parseInt("488", 16); + assertTrue(myFilter.isNoise(check)); + check = (char) Integer.parseInt("489", 16); + assertTrue(myFilter.isNoise(check)); + + assertFalse(myFilter.isNoise('A')); + } + + @Test + public void testInvalidChar() { + String[] invalidPatterns = new String[]{"", "1", "ABC", "\\u21", "#x0001-#x0000" + , "#x0001 - #x - #x0000", "#x0000 #x0022"}; + + for (String i : invalidPatterns) { + assertThrows(IllegalArgumentException.class, () -> { + myFilter.add(i); + }); + } + } + +} diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/PhoneStandardizerTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/PhoneStandardizerTest.java new file mode 100644 index 00000000000..670fcb85496 --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/PhoneStandardizerTest.java @@ -0,0 +1,21 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class PhoneStandardizerTest { + + private IStandardizer myStandardizer = new PhoneStandardizer(); + + // for rules refer to https://docs.google.com/document/d/1Vz0vYwdDsqu6WrkRyzNiBJDLGmWAej5g/edit# + + @Test + public void testPhoneNumberStandartization() { + assertEquals("111-222-3333", myStandardizer.standardize("(111) 222-33-33")); + assertEquals("111-222-3333", myStandardizer.standardize("1 1 1 2 2 2 - 3 3 3 3 ")); + assertEquals("111-222-3", myStandardizer.standardize("111-222-3")); + assertEquals("111-222-3", myStandardizer.standardize("111⅕-222-3")); + assertEquals("", myStandardizer.standardize("")); + } +} diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/TextStandardizerTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/TextStandardizerTest.java new file mode 100644 index 00000000000..79857417315 --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/TextStandardizerTest.java @@ -0,0 +1,57 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class TextStandardizerTest { + + TextStandardizer myStandardizer = new TextStandardizer(); + + @Test + public void testCleanNoiseCharacters() { + assertEquals("public", myStandardizer.standardize("\u0070\u0075\u0062\u006c\u0069\u0063\u0020\u0020\u0020\u0020")); + assertEquals("textÃ#", myStandardizer.standardize("\t\r\ntext┴\t┬\t├\t─\t┼!\\\\à #% \n")); + assertEquals("nbsp", myStandardizer.standardize("nbsp \u00A0")); + } + + @Test + public void testCleanBaseAscii() { + for (int i = 0; i < 31; i++) { + assertEquals("", myStandardizer.standardize(Character.toString((char) i))); + } + } + + @Test + public void testCleanExtendedAsciiExceptForInternationalSupport() { + for (int i = 127; i < 255; i++) { + if (!myStandardizer.isNoiseCharacter(i) || myStandardizer.isTranslate(i)) { + continue; + } + assertEquals("", myStandardizer.standardize(Character.toString((char) i)), String.format("Expected char #%s to be filtered out", i)); + } + } + + @Test + public void testExtendedUnicodeSet() { + String[] testLiterals = new String[]{ + ".", ".", + "𧚓𧚔𧜎𧜏𨩃𨩄𨩅𨩆𨩇𨩈𨩉𨩊𨩋", "𧚓𧚔𧜎𧜏𨩃𨩄𨩅𨩆𨩇𨩈𨩉𨩊𨩋", + "𐌁𐌂𐌃𐌄𐌅𐌆𐌇𐌈𐌉𐌊𐌋𐌌𐌍𐌎𐌏𐌐𐌑𐌒𐌓𐌔𐌕𐌖𐌗𐌘𐌙𐌚𐌛𐌜𐌝𐌞", "𐌁𐌂𐌃𐌄𐌅𐌆𐌇𐌈𐌉𐌊𐌋𐌌𐌍𐌎𐌏𐌐𐌑𐌒𐌓𐌔𐌕𐌖𐌗𐌘𐌙𐌚𐌛𐌜𐌝𐌞", + "𐌰𐌱𐌲𐌳𐌴𐌵𐌶𐌷𐌸𐌹𐌺𐌻𐌼𐌽𐌾𐌿𐍀𐍁𐍂𐍃𐍄𐍅𐍆𐍇𐍈𐍉𐍊", "𐌰𐌱𐌲𐌳𐌴𐌵𐌶𐌷𐌸𐌹𐌺𐌻𐌼𐌽𐌾𐌿𐍀𐍁𐍂𐍃𐍄𐍅𐍆𐍇𐍈𐍉", + "𐎀𐎁𐎂𐎃𐎄𐎅𐎆𐎇𐎈𐎉𐎊𐎋𐎌𐎍𐎎𐎏𐎐𐎑𐎒𐎓𐎔", "𐎀𐎁𐎂𐎃𐎄𐎅𐎆𐎇𐎈𐎉𐎊𐎋𐎌𐎍𐎎𐎏𐎐𐎑𐎒𐎓𐎔", + "𐏈𐏉𐏊𐏋𐏌𐏍𐏎𐏏𐏐𐏑𐏒𐏓𐏔𐏕", "", + "𐒀𐒁𐒂𐒃𐒄𐒅𐒆𐒇𐒈𐒉𐒊𐒋", "𐒀𐒁𐒂𐒃𐒄𐒅𐒆𐒇𐒈𐒉𐒊𐒋", + "𐅄𐅅𐅆𐅇", "", + "\uD802\uDD00\uD802\uDD01\uD802\uDD02\uD802\uDD03\uD802\uDD04\uD802\uDD05\uD802\uDD06\uD802\uDD07", "", + "\uD802\uDD08\uD802\uDD09\uD802\uDD0A\uD802\uDD0B\uD802\uDD0C\uD802\uDD0D\uD802\uDD0E\uD802\uDD0F", "", + "\uD802\uDD10\uD802\uDD11\uD802\uDD12\uD802\uDD13\uD802\uDD14\uD802\uDD15\uD802\uDD16\uD802\uDD17", "", + "\uD802\uDD18\uD802\uDD19\uD802\uDD1A", "", + "𐒌𐒍𐒎𐒏𐒐𐒑𐒒𐒓𐒔𐒕𐒖𐒗𐒘𐒙𐒚𐒛𐒜𐒝", "𐒌𐒍𐒎𐒏𐒐𐒑𐒒𐒓𐒔𐒕𐒖𐒗𐒘𐒙𐒚𐒛𐒜𐒝", + "𐒠𐒡𐒢𐒣𐒤𐒥𐒦𐒧𐒨𐒩", "𐒠𐒡𐒢𐒣𐒤𐒥𐒦𐒧𐒨𐒩"}; + + for (int i = 0; i < testLiterals.length; i += 2) { + assertEquals(testLiterals[i + 1], myStandardizer.standardize(testLiterals[i])); + } + } +} diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/TitleStandardizerTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/TitleStandardizerTest.java new file mode 100644 index 00000000000..4b57578d73c --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/TitleStandardizerTest.java @@ -0,0 +1,39 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvFileSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class TitleStandardizerTest { + + IStandardizer myStandardizer = new TitleStandardizer(); + + @Test + public void testSpecialCases() { + assertEquals("Prescribeit", myStandardizer.standardize("prescribeit")); + assertEquals("Meddialog", myStandardizer.standardize("MEDDIALOG")); + assertEquals("20 / 20", myStandardizer.standardize("20 / 20")); + assertEquals("20/20", myStandardizer.standardize("20/20")); + assertEquals("L.L.P.", myStandardizer.standardize("L.L.P.")); + assertEquals("Green Tractors Clow Farm Equipment/", myStandardizer.standardize("GREEN TRACTORS CLOW FARM EQUIPMENT/")); + assertEquals("Agilec - Barrie/Orillia (EPS)", myStandardizer.standardize("Agilec - Barrie/Orillia (EPS)")); + assertEquals("Clement's/Callander Ida Pharmacies", myStandardizer.standardize("CLEMENT'S/CALLANDER IDA PHARMACIES")); + assertEquals("Longley/Vickar L.L.P. Barristers & Solicitors", myStandardizer.standardize("LONGLEY/VICKAR L.L.P. BARRISTERS & SOLICITORS")); + assertEquals("-Blan", myStandardizer.standardize("~Blan")); + assertEquals("The (C/O Dr Mary Cooke)", myStandardizer.standardize("THE (C/O DR MARY COOKE)")); + assertEquals("Sarah Ann Mary Pollock", myStandardizer.standardize("SARAH ANN MARY POLLOCK")); + assertEquals("Voir...Être Vu! Opticiens", myStandardizer.standardize("VOIR...ÊTRE VU! OPTICIENS")); + assertEquals("Back in Sync: Wellness Centre", myStandardizer.standardize("BACK IN SYNC: WELLNESS CENTRE")); + assertEquals("Pearle Vision 9861 (Orchard Park S/C)", myStandardizer.standardize("PEARLE VISION 9861 (ORCHARD PARK S/C)")); + } + + @ParameterizedTest + @CsvFileSource(resources = "/organization_titles.csv", numLinesToSkip = 0) + public void testTitleOrganizationsStandardization(String theExpected, String theInput) { + String standardizedTitle = myStandardizer.standardize(theInput); + assertEquals(theExpected, standardizedTitle); + } + +} diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/where_are_the_tests.txt b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/where_are_the_tests.txt new file mode 100644 index 00000000000..413db004cb3 --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/where_are_the_tests.txt @@ -0,0 +1,2 @@ +The tests for AddressHelper, StandardizingInterceptor, AddressValidatingInterceptor, FieldValidatingInterceptor +and TerserUtils are in "hapi-fhir-structures-r4" to avoid issues with dependencies. diff --git a/hapi-fhir-server/src/test/resources/organization_titles.csv b/hapi-fhir-server/src/test/resources/organization_titles.csv new file mode 100644 index 00000000000..c39819c1d5b --- /dev/null +++ b/hapi-fhir-server/src/test/resources/organization_titles.csv @@ -0,0 +1,188 @@ +1328746 Ont Ltd / Independent Taxi,1328746 ONT LTD / INDEPENDENT TAXI +20/20 Massage,20/20 Massage +20/20 Optometrists,20/20 OPTOMETRISTS +20/20 Vision,20/20 VISION +20/20 Vision Care,20/20 VISION CARE +20/20 Vision Care Inc.,20/20 VISION CARE INC. +20/20 Vision Clinic Inc,20/20 VISION CLINIC INC +2189450 Alberta Ltd O/A Revive & Rekindle Massage Therapy,2189450 ALBERTA LTD O/A REVIVE & REKINDLE MASSAGE THERAPY +2343571 Ontario Inc O/A Westmore Wellness Rehab Clinic,2343571 ONTARIO INC O/A WESTMORE WELLNESS REHAB CLINIC +497084 Ontario Ltd. O/A Woodys Wheels,497084 ONTARIO LTD. O/A WOODYS WHEELS +Achilles Orthopaedic Shoes/Medical Devices,ACHILLES ORTHOPAEDIC SHOES/MEDICAL DEVICES +Action Sport Physio Saint-Eustache/Deux-Montagnes,ACTION SPORT PHYSIO SAINT-EUSTACHE/DEUX-MONTAGNES +Agilec - Barrie/Orillia (EPS),AGILEC - BARRIE/ORILLIA (EPS) +Agilec - Guelph/Kitchener/Waterloo (EAS),AGILEC - GUELPH/KITCHENER/WATERLOO (EAS) +Agilec - Guelph/Kitchener/Waterloo (EPS),AGILEC - GUELPH/KITCHENER/WATERLOO (EPS) +Ags Rehab Solutions - Halton/Peel,AGS REHAB SOLUTIONS - HALTON/PEEL +Ags Rehab Solutions Inc - Barrie/Orillia,AGS REHAB SOLUTIONS INC - BARRIE/ORILLIA +Ags Rehab Solutions Inc - Thunder Bay/Dryden/Kenora,AGS REHAB SOLUTIONS INC - THUNDER BAY/DRYDEN/KENORA +Ags Rehab Solutions Inc-Guelph/Kitchener/Waterloo,AGS REHAB SOLUTIONS INC-GUELPH/KITCHENER/WATERLOO +Aladdin Geleidi O/A 1296864 Alberta Ltd,ALADDIN GELEIDI O/A 1296864 ALBERTA LTD +Allan McGavin Sports Medicine Centre @ Usb,ALLAN MCGAVIN SPORTS MEDICINE CENTRE @ USB +Allan McGavin Sports Medicine Centre Physiotherapy @ Plaza of Nations,ALLAN MCGAVIN SPORTS MEDICINE CENTRE PHYSIOTHERAPY @ PLAZA OF NATIONS +Amber-Lynn O'Brien,amber-lynn O€�brien +Angela Wing Wen Lam,ANGELA WING WEN  LAM +Ascenseurs Lumar/Concord Quebec Inc.,ASCENSEURS LUMAR/CONCORD QUEBEC INC. +Back in Sync: Wellness Centre,BACK IN SYNC: WELLNESS CENTRE +Balance: Psychology and Brain Health,BALANCE: PSYCHOLOGY AND BRAIN HEALTH +Bayshore Therapy and Rehab - Barrie/Orillia,BAYSHORE THERAPY AND REHAB - BARRIE/ORILLIA +Bayshore Therapy and Rehab - Guelph/Kitchener/Waterloo,BAYSHORE THERAPY AND REHAB - GUELPH/KITCHENER/WATERLOO +Bayshore Therapy and Rehab - Halton/Peel,BAYSHORE THERAPY AND REHAB - HALTON/PEEL +Bayshore Therapy and Rehab - Thunder Bay/Dryden/Kenora,BAYSHORE THERAPY AND REHAB - THUNDER BAY/DRYDEN/KENORA +Bing Siang Gan,BING SIANG  GAN +Bio-Ped/2059779 Ontario Ltd,BIO-PED/2059779 ONTARIO LTD +Bloor : Keele Chiropractic,BLOOR : KEELE CHIROPRACTIC +Brampton/Bramalea Kwik Kab,BRAMPTON/BRAMALEA KWIK KAB +Brock Community Health Centre/Nursing,BROCK COMMUNITY HEALTH CENTRE/NURSING +C'Est la Vue !,C'EST LA VUE ! +Can-Weld/Can-Fab,CAN-WELD/CAN-FAB +Cbi - Langley / Oasis Sports Injury Centre,CBI - LANGLEY / OASIS SPORTS INJURY CENTRE +Centre de Sante Communitaire Hamilton/Niagara,CENTRE DE SANTE COMMUNITAIRE HAMILTON/NIAGARA +Cheelcare / 9302204 Canada Inc.,CHEELCARE / 9302204 CANADA INC. +Clement's/Callander Ida Pharmacies,CLEMENT'S/CALLANDER IDA PHARMACIES +Clsc/Chsld Des Etchemin,CLSC/CHSLD DES ETCHEMIN +Cowichan Eyecare-Chemainus,COWICHAN EYECARE~CHEMAINUS +Crawford Healthcare Management - Guelph/Kitchener,CRAWFORD HEALTHCARE MANAGEMENT - GUELPH/KITCHENER +Daniel Man Tat Wong,DANIEL MAN TAT  WONG +Dominique Nadon Ssuo/Uohs,DOMINIQUE NADON SSUO/UOHS +Dormez-Vous / Sleep Country,DORMEZ-VOUS / SLEEP COUNTRY +Dr Todd E Mazzuca O/A Northstone Chiropractic Paris St,DR TODD E MAZZUCA O/A NORTHSTONE CHIROPRACTIC PARIS ST +Dr. Atoosa Chiropractor/Acupuncture,DR. ATOOSA CHIROPRACTOR/ACUPUNCTURE +Dufferin Drug Mart O/A Pharmadx Drugs Ltd,DUFFERIN DRUG MART O/A PHARMADX DRUGS LTD +Emergency Assoc/Univ of Roc,EMERGENCY ASSOC/UNIV OF ROC +Eric Johnson/Hear at Last,ERIC JOHNSON/HEAR AT LAST +Eyemate Vision/Crystal Clear Optical,EYEMATE VISION/CRYSTAL CLEAR OPTICAL +Eyes Inspire / Visionworks,EYES INSPIRE / VISIONWORKS +Feet First Pedorthic/Nursing Foot Care Clinic,FEET FIRST PEDORTHIC/NURSING FOOT CARE CLINIC +Fine + Well: Health and Chiropractic,FINE + WELL: HEALTH AND CHIROPRACTIC +Foot Solutions/2288564 Ontario Inc,FOOT SOLUTIONS/2288564 ONTARIO INC +Friuli Benevolent Corp./Friuli Terrace,FRIULI BENEVOLENT CORP./FRIULI TERRACE +Go! Physiotherapy Sports and Wellness Centre,GO! PHYSIOTHERAPY SPORTS AND WELLNESS CENTRE +Gobi Ratnaswami Ganapathy,GOBI RATNASWAMI  GANAPATHY +Gordon Josephson/Gilmour Psychological Services,GORDON JOSEPHSON/GILMOUR PSYCHOLOGICAL SERVICES +Grand River Hospital - Kitchener/Waterloo Health Centre,GRAND RIVER HOSPITAL - KITCHENER/WATERLOO HEALTH CENTRE +Green Tractors Clow Farm Equipment/,GREEN TRACTORS CLOW FARM EQUIPMENT/ +H/Q Healthquest,H/Q HEALTHQUEST +Hear at Last/Phc Canada Scarborough,HEAR AT LAST/PHC CANADA SCARBOROUGH +Hearinglife - Sydney,HEARINGLIFE – SYDNEY +Howard Chung C/O Lishan Management Co. Ltd,HOWARD CHUNG C/O LISHAN MANAGEMENT CO. LTD +Ian Gray/ Sound Ideas Audiology,IAN GRAY/ SOUND IDEAS AUDIOLOGY +Iris-292-Centre Piazzazzurri,IRIS-292-CENTRE PIAZZ`AZZURRI +Ismp/Tanya Armstrong,ISMP/TANYA ARMSTRONG +Jeffrey Thomas Hovey,JEFFREY THOMAS  HOVEY +John D Franks/Access Hearing Care,JOHN D FRANKS/ACCESS HEARING CARE +John David Jacques Bender,JOHN DAVID JACQUES   BENDER +Kenneth Bernard Sabourin,KENNETH BERNARD  SABOURIN +Killaloe Supermarket/Aj's Killaloe Convenience,KILLALOE SUPERMARKET/AJ'S KILLALOE CONVENIENCE +Lakeshore General Hospitalc/Oaccounting Dept,LAKESHORE GENERAL HOSPITALC/OACCOUNTING DEPT +Le Groupe Forget/Audioprothesistes,LE GROUPE FORGET/AUDIOPROTHESISTES +Leaps and Bounds: Performance Rehabilitation,LEAPS AND BOUNDS: PERFORMANCE REHABILITATION +Lifemark Health Corp - Barrie/Orillia,LIFEMARK HEALTH CORP - BARRIE/ORILLIA +Lifemark Health Corp - Guelph/Kitchener/Waterloo,LIFEMARK HEALTH CORP - GUELPH/KITCHENER/WATERLOO +Lifemark Health Corp - Halton / Peel,LIFEMARK HEALTH CORP - HALTON / PEEL +Listenup! Canada - Dundas West,LISTENUP! CANADA - DUNDAS WEST +Listenup! Canada - Toronto,LISTENUP! CANADA - TORONTO +Longley/Vickar L.L.P. Barristers & Solicitors,LONGLEY/VICKAR L.L.P. BARRISTERS & SOLICITORS +Lorraine McLeod O/A Union Taxi,LORRAINE MCLEOD O/A UNION TAXI +Manpreet Birring,MANPREET  BIRRING +March of Dimes Canada - Barrie/Orillia (EAS),MARCH OF DIMES CANADA - BARRIE/ORILLIA (EAS) +March of Dimes Canada - Barrie/Orillia (EPS),MARCH OF DIMES CANADA - BARRIE/ORILLIA (EPS) +March of Dimes Canada - Guelph/Kitchener/Waterloo (EAS),MARCH OF DIMES CANADA - GUELPH/KITCHENER/WATERLOO (EAS) +March of Dimes Canada - Guelph/Kitchener/Waterloo (EPS),MARCH OF DIMES CANADA - GUELPH/KITCHENER/WATERLOO (EPS) +March of Dimes Canada - Halton/Peel (EAS),MARCH OF DIMES CANADA - HALTON/PEEL (EAS) +March of Dimes Canada - Halton/Peel (EPS),MARCH OF DIMES CANADA - HALTON/PEEL (EPS) +March of Dimes Canada - Thunder Bay Dryden/Kenora (EAS),MARCH OF DIMES CANADA - THUNDER BAY DRYDEN/KENORA (EAS) +March of Dimes Canada - Thunder Bay Dryden/Kenora (EPS),MARCH OF DIMES CANADA - THUNDER BAY DRYDEN/KENORA (EPS) +Marion Baechler,MARION  BAECHLER +Markham Family Medicine Teaching Unit/Health For All Fht,MARKHAM FAMILY MEDICINE TEACHING UNIT/HEALTH FOR ALL FHT +Med-E-Ox/Mobility in Motion,MED-E-OX/MOBILITY IN MOTION +Medcare Clinics @ Walmart Pen Centre,MEDCARE CLINICS @ WALMART PEN CENTRE +Medical Center For Foot/Ankle,MEDICAL CENTER FOR FOOT/ANKLE +Mend|Rx Bedford,MEND|RX BEDFORD +Michael David Williams,MICHAEL DAVID  WILLIAMS +Michelle Harvey @ Twelfth Avenue Acupuncture and Herb Clinic,MICHELLE HARVEY @ TWELFTH AVENUE ACUPUNCTURE AND HERB CLINIC +Minister of Finance C/O Ministry of Health,MINISTER OF FINANCE C/O MINISTRY OF HEALTH +Miracle Ear/Fred Hawkins,MIRACLE EAR/FRED HAWKINS +Mount Sinai Rehab C/O Mt Sinai Hosp,MOUNT SINAI REHAB C/O MT SINAI HOSP +Moving Along... Your !,MOVING ALONG... YOUR ! +Ms Sheila Wolanski/Mid-Island Home Support,MS SHEILA WOLANSKI/MID-ISLAND HOME SUPPORT +Naomi Anne Ecob,NAOMI ANNE  ECOB +National Orthotic Centre/# 1703639,NATIONAL ORTHOTIC CENTRE/# 1703639 +National Orthotic Centre/#1649481,NATIONAL ORTHOTIC CENTRE/#1649481 +New Sudbury/Val Caron Family Vision Ctre,NEW SUDBURY/VAL CARON FAMILY VISION CTRE +Noelle Bethea,Noelle  Bethea +North York General Hospital/Audiology,NORTH YORK GENERAL HOSPITAL/AUDIOLOGY +Not Just Backs! Chiropractic,NOT JUST BACKS! CHIROPRACTIC +Oh! Lunettes Par Sardi-Nicopoulos,OH! LUNETTES PAR SARDI-NICOPOULOS +Omod Kitchen/Guelph/Waterloo,OMOD KITCHEN/GUELPH/WATERLOO +Ontario Hearing Institute C/O Dorothy Bravo,ONTARIO HEARING INSTITUTE C/O DOROTHY BRAVO +Optical 20/20 of Whitby,OPTICAL 20/20 OF WHITBY +Optical 6/6,OPTICAL 6/6 +Ortho Ml Inc./Respir-O-Max,ORTHO ML INC./RESPIR-O-MAX +Ossur Canada Inc C/OT44606,OSSUR CANADA INC C/OT44606 +Ot Consulting/Treatment Services Ltd,OT CONSULTING/TREATMENT SERVICES LTD +Ottawa Eyelabs Inc C/O Vision Plus,OTTAWA EYELABS INC C/O VISION PLUS +Ottawa Health: Performance and Rehabilitation,OTTAWA HEALTH: PERFORMANCE AND REHABILITATION +Pearle Vision 9861 (Orchard Park S/C),PEARLE VISION 9861 (ORCHARD PARK S/C) +Pfahl's Drugs/Home Health Care,PFAHL'S DRUGS/HOME HEALTH CARE +Physio F/X Ltd,PHYSIO F/X LTD +Physiotherapy Works!,PHYSIOTHERAPY WORKS! +Pinellas County Ems D/B/A Sunstar,PINELLAS COUNTY EMS D/B/A SUNSTAR +Pmb/Emergency Medicine of in LLC,PMB/EMERGENCY MEDICINE OF IN LLC +Professional Hearing Clinic Inc/Connect Hearing,PROFESSIONAL HEARING CLINIC INC/CONNECT HEARING +Prothotics/Healthwest,PROTHOTICS/HEALTHWEST +Regents of The U of M U/M Health System,REGENTS OF THE U OF M U/M HEALTH SYSTEM +Regents of U/M - Medequip,REGENTS OF U/M - MEDEQUIP +Rehabilitation Network Canada - Guelph/Kitchener/Waterloo,REHABILITATION NETWORK CANADA - GUELPH/KITCHENER/WATERLOO +Rehabilitation Network Canada - Halton/Peel,REHABILITATION NETWORK CANADA - HALTON/PEEL +Rehabilitation Network Canada Inc - Halton/Peel,REHABILITATION NETWORK CANADA INC - HALTON/PEEL +Rehamed Inc O/A Humbertown Physiotherapy,REHAMED INC O/A HUMBERTOWN PHYSIOTHERAPY +Retire -at- Home Services/North York,RETIRE -AT- HOME SERVICES/NORTH YORK +Retire-at-Home 0SHAWA/Clarington,RETIRE-AT-HOME 0SHAWA/CLARINGTON +Richard Kievitz/Hear at Last,RICHARD KIEVITZ/HEAR AT LAST +Sam Ibraham/Canes Family Hlth Team,SAM IBRAHAM/CANES FAMILY HLTH TEAM +Sarah Ann Mary Pollock,SARAH ANN MARY POLLOCK +Sharp Healthcare Pfs/Icd Dept,SHARP HEALTHCARE PFS/ICD DEPT +Shelburne Medical Drugs O/A Caravaggio Ida,SHELBURNE MEDICAL DRUGS O/A CARAVAGGIO IDA +Silver Cross O/A 3004773,SILVER CROSS O/A 3004773 +Six Nations Ltc/Hcc,SIX NATIONS LTC/HCC +Sole Science Inc/Co St Thomas,SOLE SCIENCE INC/CO ST THOMAS +Sonago Pharmacy Ltd C/O Main Drug Mart,SONAGO PHARMACY LTD C/O MAIN DRUG MART +Soul: The Wheelchair Studio,SOUL: THE WHEELCHAIR STUDIO +Spi Health & Safety Inc C/O Acctng,SPI HEALTH & SAFETY INC C/O ACCTNG +St Joseph Nuclear Med C/O St Josephs Hlth Ctr,ST JOSEPH NUCLEAR MED C/O ST JOSEPHS HLTH CTR +St. Meena Pharmacy Ltd O/A College Ctr Pharmacy,ST. MEENA PHARMACY LTD O/A COLLEGE CTR PHARMACY +Staples / Bd #235 Woodstock,STAPLES / BD #235 WOODSTOCK +Staples/Home Depot,STAPLES/HOME DEPOT +Sylvia Pudsey O/A The Renfrew Learning Centre,SYLVIA PUDSEY O/A THE RENFREW LEARNING CENTRE +Tarit Kumar Kanungo,Tarit Kumar  Kanungo +The City of Winnipeg/Fire Paramedic Service,THE CITY OF WINNIPEG/FIRE PARAMEDIC SERVICE +The Hearing Loss Clinic Inc /Sabrina Rhodes,THE HEARING LOSS CLINIC INC /SABRINA RHODES +The Physio Clinic @ West Durham,THE PHYSIO CLINIC @ WEST DURHAM +The Rehabilitation Ctre Finance Dept/Accts Rec,THE REHABILITATION CTRE FINANCE DEPT/ACCTS REC +The Therapy Centre (C/O Dr Mary Cooke),THE THERAPY CENTRE (C/O DR MARY COOKE) +Town of Windham C/O Comstar Ambulance Billing Ser,TOWN OF WINDHAM C/O COMSTAR AMBULANCE BILLING SER +Tram Anh Thi Nguyen,TRAM ANH THI  NGUYEN +Trillium Health Partners-Qhc Finance/Accts Rec-Cvh,TRILLIUM HEALTH PARTNERS-QHC FINANCE/ACCTS REC-CVH +Trimble Europe B V C/O T10271C,TRIMBLE EUROPE B V C/O T10271C +True North Imaging / 1582235 Ontario Ltd.,TRUE NORTH IMAGING / 1582235 ONTARIO LTD. +Tupley Optical Inc. C/O Aspen Eye Care,TUPLEY OPTICAL INC. C/O ASPEN EYE CARE +Uwo Staff/Faculty Family Practice Clinic,UWO STAFF/FACULTY FAMILY PRACTICE CLINIC +Visionworks / Eyes Inpsire,VISIONWORKS / EYES INPSIRE +Whole>Sum Massage,WHOLE>SUM Massage +William L. Trenwith / Paul J. Trenwith,WILLIAM L. TRENWITH / PAUL J. TRENWITH +Wilson Medical Centre/Evans,WILSON MEDICAL CENTRE/EVANS +Work Fitness Plus C/O Oakville Trafalgar Mh,WORK FITNESS PLUS C/O OAKVILLE TRAFALGAR MH +Ymca of Simcoe/Muskoka,YMCA OF SIMCOE/MUSKOKA +Young Drivers of Canada/ Maitland For Lincoln,YOUNG DRIVERS OF CANADA/ MAITLAND FOR LINCOLN +Zest! Rehabilitation Health & Wellness,ZEST! REHABILITATION HEALTH & WELLNESS +-Academy of Learning Owen Sound (Priv.),~ACADEMY OF LEARNING OWEN SOUND (PRIV.) +-Appletree Medical Centre,~APPLETREE MEDICAL CENTRE +-Brameast Family Physicians,~BRAMEAST FAMILY PHYSICIANS +-C. L. Consulting,~C. L. CONSULTING +-My Health Centre,~MY HEALTH CENTRE +-Upper Canada Hearing & Speech Centre,~UPPER CANADA HEARING & SPEECH CENTRE +-Whitby Clinic,~WHITBY CLINIC +Sport Physio West Island Inc./Actio,Sport Physio West Island Inc./Actio +Voir...Être Vu! Opticiens,VOIR...ÊTRE VU! OPTICIENS +Vpi Inc. - Halton/Peel,VPI INC. - HALTON/PEEL diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/address/AddressHelperTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/address/AddressHelperTest.java new file mode 100644 index 00000000000..25bc5faf117 --- /dev/null +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/address/AddressHelperTest.java @@ -0,0 +1,81 @@ +package ca.uhn.fhir.rest.server.interceptor.address; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.server.interceptor.validation.helpers.AddressHelper; +import org.hl7.fhir.r4.model.Address; +import org.hl7.fhir.r4.model.HumanName; +import org.hl7.fhir.r4.model.StringType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class AddressHelperTest { + + private static FhirContext ourContext = FhirContext.forR4(); + + @Test + void testInvalid() { + HumanName name = new HumanName(); + name.setFamily("Test"); + + final AddressHelper helper = new AddressHelper(null, name); + assertThrows(IllegalStateException.class, () -> { + helper.getCountry(); + }); + + assertThrows(IllegalArgumentException.class, () -> { + new AddressHelper(null, new StringType("this will blow up")); + }); + } + + @Test + void getCountry() { + Address a = new Address(); + a.setCountry("Test"); + + AddressHelper helper = new AddressHelper(null, a); + assertEquals("Test", helper.getCountry()); + } + + @Test + void getParts() { + Address a = new Address(); + a.setCity("Hammer"); + + AddressHelper helper = new AddressHelper(null, a); + helper.setDelimiter("; "); + assertEquals("Hammer", helper.getParts()); + + a.addLine("Street"); + a.setPostalCode("L9C6L6"); + assertEquals("Hammer; L9C6L6", helper.getParts()); + } + + @Test + void getLine() { + Address a = new Address(); + a.addLine("Unit 10"); + a.setCity("Hammer"); + + AddressHelper helper = new AddressHelper(null, a); + assertEquals("Unit 10", helper.getLine()); + + a.addLine("100 Main St."); + assertEquals("Unit 10, 100 Main St.", helper.getLine()); + } + + @Test + void testSetFields() { + Address a = new Address(); + + AddressHelper helper = new AddressHelper(ourContext, a); + helper.addLine("Line 1").addLine("Line 2"); + helper.setCity("Hammer"); + helper.setState("State"); + helper.setCountry("Country"); + helper.setText("Some Text Too"); + assertEquals("Some Text Too, Line 1, Line 2, Hammer, State, Country", helper.toString()); + } + +} diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/interceptors/StandardizingInterceptorTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/interceptors/StandardizingInterceptorTest.java new file mode 100644 index 00000000000..6ab6b92b30b --- /dev/null +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/interceptors/StandardizingInterceptorTest.java @@ -0,0 +1,113 @@ +package ca.uhn.fhir.rest.server.interceptor.s13n.interceptors; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import com.fasterxml.jackson.databind.ObjectMapper; +import ca.uhn.fhir.rest.server.interceptor.s13n.StandardizingInterceptor; +import org.hl7.fhir.instance.model.api.IBase; +import org.hl7.fhir.r4.model.ContactPoint; +import org.hl7.fhir.r4.model.Patient; +import org.hl7.fhir.r4.model.Person; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Map; + +import static ca.uhn.fhir.rest.server.interceptor.s13n.StandardizingInterceptor.STANDARDIZATION_DISABLED_HEADER; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class StandardizingInterceptorTest { + + private static final String CONFIG = + "{\n" + + "\t\"Person\" : {\n" + + "\t\t\"Person.name.family\" : \"NAME_FAMILY\",\n" + + "\t\t\"Person.name.given\" : \"NAME_GIVEN\",\n" + + "\t\t\"Person.telecom.where(system='phone').value\" : \"PHONE\"\n" + + "\t\t},\n" + + "\t\"Patient\" : {\n" + + "\t\t\"name.given\" : \"NAME_GIVEN\",\n" + + "\t\t\"telecom.where(system='phone').value\" : \"PHONE\"\n" + + "\t\t}\n" + + "}"; + + private static final String BAD_CONFIG = "{ \"Person\" : { \"Person.name.family\" : \"org.nonexistent.Standardizer\"}}"; + + private static FhirContext ourCtx = FhirContext.forR4(); + + private RequestDetails myRequestDetails; + + private StandardizingInterceptor myInterceptor = new StandardizingInterceptor(); + + @BeforeEach + public void init() throws Exception { + myInterceptor = new StandardizingInterceptor(new ObjectMapper().readValue(CONFIG, Map.class)); + + myRequestDetails = mock(RequestDetails.class); + when(myRequestDetails.getFhirContext()).thenReturn(ourCtx); + } + + @Test + public void testNameStandardization() throws Exception { + Person p = new Person(); + p.addName().setFamily("macdouglas1").addGiven("\nJoHn"); + p.addName().setFamily("o'BrIaN").addGiven("jIM\t"); + + myInterceptor.resourcePreUpdate(myRequestDetails, null, p); + + assertEquals("John MacDouglas1", p.getName().get(0).getNameAsSingleString()); + assertEquals("Jim O'Brian", p.getName().get(1).getNameAsSingleString()); + } + + @Test + public void testNullsWork() { + try { + myInterceptor.resourcePreCreate(myRequestDetails, null); + } catch (Exception e) { + fail(); + } + } + + @Test + public void testBadConfig() throws Exception { + myInterceptor = new StandardizingInterceptor(new ObjectMapper().readValue(BAD_CONFIG, Map.class)); + + try { + myInterceptor.resourcePreCreate(myRequestDetails, new Person()); + fail(); + } catch (Exception e) { + } + } + + @Test + public void testDisablingValidationViaHeader() { + when(myRequestDetails.getHeaders(eq(STANDARDIZATION_DISABLED_HEADER))).thenReturn(Arrays.asList(new String[]{"True"})); + + Person p = new Person(); + p.addName().setFamily("non'normalized").addGiven("name"); + + myInterceptor.resourcePreUpdate(myRequestDetails, null, p); + + assertEquals("name non'normalized", p.getName().get(0).getNameAsSingleString()); + } + + @Test + public void testTelecomStandardization() throws Exception { + Person p = new Person(); + p.addTelecom().setValue(" Email@email.com").setSystem(ContactPoint.ContactPointSystem.EMAIL); + p.addTelecom().setValue("1234567890").setSystem(ContactPoint.ContactPointSystem.PHONE); + + myInterceptor.resourcePreUpdate(myRequestDetails, null, p); + + assertEquals(" Email@email.com", p.getTelecom().get(0).getValue(), "Expected email to remain the same"); + assertEquals("123-456-7890", p.getTelecom().get(1).getValue()); + } +} diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidatingInterceptorTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidatingInterceptorTest.java new file mode 100644 index 00000000000..61c1affa123 --- /dev/null +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidatingInterceptorTest.java @@ -0,0 +1,164 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.address; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import org.checkerframework.checker.units.qual.A; +import org.hl7.fhir.instance.model.api.IBase; +import org.hl7.fhir.r4.model.Address; +import org.hl7.fhir.r4.model.Person; +import org.hl7.fhir.r4.model.StringType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.Mockito; + +import javax.annotation.Nonnull; +import java.util.Arrays; +import java.util.Properties; + +import static ca.uhn.fhir.rest.server.interceptor.s13n.StandardizingInterceptor.STANDARDIZATION_DISABLED_HEADER; +import static ca.uhn.fhir.rest.server.interceptor.validation.address.AddressValidatingInterceptor.ADDRESS_VALIDATION_DISABLED_HEADER; +import static ca.uhn.fhir.rest.server.interceptor.validation.address.AddressValidatingInterceptor.PROPERTY_VALIDATOR_CLASS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AddressValidatingInterceptorTest { + + private static FhirContext ourCtx = FhirContext.forR4(); + + private AddressValidatingInterceptor myInterceptor; + + private IAddressValidator myValidator; + + private RequestDetails myRequestDetails; + + @Test + void start() throws Exception { + AddressValidatingInterceptor interceptor = new AddressValidatingInterceptor(new Properties()); + assertNull(interceptor.getAddressValidator()); + + Properties props = new Properties(); + props.setProperty(PROPERTY_VALIDATOR_CLASS, "RandomService"); + try { + new AddressValidatingInterceptor(props); + fail(); + } catch (Exception e) { + // expected + } + + props.setProperty(PROPERTY_VALIDATOR_CLASS, TestAddressValidator.class.getName()); + interceptor = new AddressValidatingInterceptor(props); + assertNotNull(interceptor.getAddressValidator()); + } + + @BeforeEach + void setup() { + myValidator = mock(IAddressValidator.class); + when(myValidator.isValid(any(), any())).thenReturn(mock(AddressValidationResult.class)); + + myRequestDetails = mock(RequestDetails.class); + when(myRequestDetails.getFhirContext()).thenReturn(ourCtx); + + Properties properties = getProperties(); + myInterceptor = new AddressValidatingInterceptor(properties); + myInterceptor.setAddressValidator(myValidator); + } + + @Nonnull + private Properties getProperties() { + Properties properties = new Properties(); + properties.setProperty(PROPERTY_VALIDATOR_CLASS, TestAddressValidator.class.getName()); + return properties; + } + + @Test + public void testDisablingValidationViaHeader() { + when(myRequestDetails.getHeaders(eq(ADDRESS_VALIDATION_DISABLED_HEADER))).thenReturn(Arrays.asList(new String[]{"True"})); + + Person p = new Person(); + AddressValidatingInterceptor spy = Mockito.spy(myInterceptor); + spy.resourcePreCreate(myRequestDetails, p); + + Mockito.verify(spy, times(0)).validateAddress(any(), any()); + } + + @Test + public void testValidationServiceError() { + myValidator = mock(IAddressValidator.class); + when(myValidator.isValid(any(), any())).thenThrow(new RuntimeException()); + myInterceptor.setAddressValidator(myValidator); + + Address address = new Address(); + myInterceptor.validateAddress(address, ourCtx); + assertValidated(address, "not-validated"); + } + + @Test + void validate() { + Address address = new Address(); + address.addLine("Line"); + address.setCity("City"); + + myInterceptor.validateAddress(address, ourCtx); + assertValidated(address, "invalid"); + } + + private void assertValidated(Address theAddress, String theValidationResult) { + assertTrue(theAddress.hasExtension()); + assertEquals(1, theAddress.getExtension().size()); + assertEquals(IAddressValidator.ADDRESS_VALIDATION_EXTENSION_URL, theAddress.getExtensionFirstRep().getUrl()); + assertEquals(theValidationResult, theAddress.getExtensionFirstRep().getValueAsPrimitive().toString()); + } + + @Test + void validateOnCreate() { + Address address = new Address(); + address.addLine("Line"); + address.setCity("City"); + + Person person = new Person(); + person.addAddress(address); + + myInterceptor.resourcePreCreate(myRequestDetails, person); + + assertValidated(person.getAddressFirstRep(), "invalid"); + } + + @Test + void validateOnUpdate() { + Address address = new Address(); + address.addLine("Line"); + address.setCity("City"); + address.addExtension(IAddressValidator.ADDRESS_VALIDATION_EXTENSION_URL, new StringType("...")); + + Address address2 = new Address(); + address2.addLine("Line 2"); + address2.setCity("City 2"); + + Person person = new Person(); + person.addAddress(address); + person.addAddress(address2); + + myInterceptor.resourcePreUpdate(myRequestDetails, null, person); + + verify(myValidator, times(1)).isValid(any(), any()); + assertValidated(person.getAddress().get(0), "..."); + assertValidated(person.getAddress().get(1), "invalid"); + } + + public static class TestAddressValidator implements IAddressValidator { + @Override + public AddressValidationResult isValid(IBase theAddress, FhirContext theFhirContext) throws AddressValidationException { + return null; + } + } +} diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/BaseRestfulValidatorTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/BaseRestfulValidatorTest.java new file mode 100644 index 00000000000..db1d572c8ba --- /dev/null +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/BaseRestfulValidatorTest.java @@ -0,0 +1,63 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.address.impl; + +import ca.uhn.fhir.context.FhirContext; +import com.fasterxml.jackson.databind.JsonNode; +import ca.uhn.fhir.rest.server.interceptor.validation.address.AddressValidationResult; +import org.hl7.fhir.instance.model.api.IBase; +import org.hl7.fhir.r4.model.Address; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class BaseRestfulValidatorTest { + + @Test + public void testHappyPath() throws Exception { + ResponseEntity responseEntity = mock(ResponseEntity.class); + when(responseEntity.getStatusCode()).thenReturn(HttpStatus.OK); + when(responseEntity.getBody()).thenReturn("{}"); + + TestRestfulValidator val = spy(new TestRestfulValidator(responseEntity)); + assertNotNull(val.isValid(new Address(), FhirContext.forR4())); + + verify(val, times(1)).getResponseEntity(any(IBase.class), any(FhirContext.class)); + verify(val, times(1)).getValidationResult(any(), any(), any()); + } + + @Test + public void testIsValid() throws Exception { + ResponseEntity responseEntity = mock(ResponseEntity.class); + when(responseEntity.getStatusCode()).thenReturn(HttpStatus.REQUEST_TIMEOUT); + + TestRestfulValidator val = new TestRestfulValidator(responseEntity); + try { + assertNotNull(val.isValid(new Address(), FhirContext.forR4())); + fail(); + } catch (Exception e) { + } + } + + private static class TestRestfulValidator extends BaseRestfulValidator { + ResponseEntity myResponseEntity; + + public TestRestfulValidator(ResponseEntity theResponseEntity) { + super(null); + myResponseEntity = theResponseEntity; + } + + @Override + protected AddressValidationResult getValidationResult(AddressValidationResult theResult, JsonNode response, FhirContext theFhirContext) throws Exception { + return new AddressValidationResult(); + } + + @Override + protected ResponseEntity getResponseEntity(IBase theAddress, FhirContext theFhirContext) throws Exception { + return myResponseEntity; + } + } + + +} diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/LoquateAddressValidatorTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/LoquateAddressValidatorTest.java new file mode 100644 index 00000000000..be047783458 --- /dev/null +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/LoquateAddressValidatorTest.java @@ -0,0 +1,179 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.address.impl; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.server.interceptor.validation.address.AddressValidationException; +import ca.uhn.fhir.rest.server.interceptor.validation.address.AddressValidationResult; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.hl7.fhir.r4.model.Address; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpEntity; +import org.springframework.web.client.RestTemplate; + +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +class LoquateAddressValidatorTest { + + private static final String REQUEST = "{\n" + + " \"Key\" : \"MY_KEY\",\n" + + " \"Geocode\" : false,\n" + + " \"Addresses\" : [ {\n" + + " \"Address1\" : \"Line 1\",\n" + + " \"Address2\" : \"Line 2\",\n" + + " \"Locality\" : \"City\",\n" + + " \"PostalCode\" : \"POSTAL\",\n" + + " \"Country\" : \"Country\"\n" + + " } ]\n" + + "}"; + + private static final String RESPONSE_INVALID = "[\n" + + " {\n" + + " \"Input\": {\n" + + " \"Address\": \"\"\n" + + " }\n" + + " }\n" + + "]"; + + private static final String RESPONSE_INVALID_ADDRESS = "[\n" + + " {\n" + + " \"Input\": {\n" + + " \"Address\": \"\"\n" + + " },\n" + + " \"Matches\": [\n" + + " {\n" + + " \"AQI\": \"C\",\n" + + " \"Address\": \"\"\n" + + " }\n" + + " ]\n" + + " }\n" + + "]"; + + private static final String RESPONSE_VALID_ADDRESS = "[\n" + + " {\n" + + " \"Input\": {\n" + + " \"Address\": \"\"\n" + + " },\n" + + " \"Matches\": [\n" + + " {\n" + + " \"AQI\": \"A\",\n" + + " \"Address\": \"My Valid Address\"\n" + + " }\n" + + " ]\n" + + " }\n" + + "]"; + + private static final String RESPONSE_INVALID_KEY = "{\n" + + " \"Number\": 2,\n" + + " \"Description\": \"Unknown key\",\n" + + " \"Cause\": \"The key you are using to access the service was not found.\",\n" + + " \"Resolution\": \"Please check that the key is correct. It should be in the form AA11-AA11-AA11-AA11.\"\n" + + "}"; + + private static FhirContext ourCtx = FhirContext.forR4(); + + private LoquateAddressValidator myValidator; + + private Properties myProperties; + + @BeforeEach + public void initValidator() { + myProperties = new Properties(); + myProperties.setProperty(LoquateAddressValidator.PROPERTY_SERVICE_KEY, "MY_KEY"); + myValidator = new LoquateAddressValidator(myProperties); + } + + @Test + public void testInvalidInit() { + try { + new LoquateAddressValidator(new Properties()); + fail(); + } catch (Exception e) { + } + } + + @Test + public void testInvalidAddressValidationResponse() throws Exception { + try { + AddressValidationResult res = myValidator.getValidationResult(new AddressValidationResult(), + new ObjectMapper().readTree(RESPONSE_INVALID), ourCtx); + fail(); + } catch (AddressValidationException e) { + } + } + + @Test + public void testRequestBody() { + try { + assertEquals(clear(REQUEST), clear(myValidator.getRequestBody(ourCtx, getAddress()))); + } catch (JsonProcessingException e) { + fail(); + } + } + + private String clear(String theString) { + theString = theString.replaceAll("\n", ""); + theString = theString.replaceAll("\r", ""); + return theString.trim(); + } + + @Test + public void testServiceCalled() { + Address address = getAddress(); + + final RestTemplate template = mock(RestTemplate.class); + + LoquateAddressValidator val = new LoquateAddressValidator(myProperties) { + @Override + protected RestTemplate newTemplate() { + return template; + } + }; + + try { + val.getResponseEntity(address, ourCtx); + } catch (Exception e) { + fail(); + } + + verify(template, times(1)).postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)); + } + + private Address getAddress() { + Address address = new Address(); + address.addLine("Line 1").addLine("Line 2").setCity("City").setPostalCode("POSTAL").setCountry("Country"); + return address; + } + + @Test + public void testSuccessfulResponses() throws Exception { + AddressValidationResult res = myValidator.getValidationResult(new AddressValidationResult(), + new ObjectMapper().readTree(RESPONSE_INVALID_ADDRESS), ourCtx); + assertFalse(res.isValid()); + + res = myValidator.getValidationResult(new AddressValidationResult(), + new ObjectMapper().readTree(RESPONSE_VALID_ADDRESS), ourCtx); + assertTrue(res.isValid()); + assertEquals("My Valid Address", res.getValidatedAddressString()); + } + + @Test + public void testErrorResponses() throws Exception { + assertThrows(AddressValidationException.class, () -> { + myValidator.getValidationResult(new AddressValidationResult(), + new ObjectMapper().readTree(RESPONSE_INVALID_KEY), ourCtx); + }); + } + +} diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/MelissaAddressValidatorTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/MelissaAddressValidatorTest.java new file mode 100644 index 00000000000..2b229860b74 --- /dev/null +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/MelissaAddressValidatorTest.java @@ -0,0 +1,139 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.address.impl; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.server.interceptor.validation.address.AddressValidationException; +import ca.uhn.fhir.rest.server.interceptor.validation.address.AddressValidationResult; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.hl7.fhir.r4.model.Address; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +class MelissaAddressValidatorTest { + + private static final String RESPONSE_INVALID_ADDRESS = "{\n" + + " \"Version\": \"3.0.1.160\",\n" + + " \"TransmissionReference\": \"1\",\n" + + " \"TransmissionResults\": \"\",\n" + + " \"TotalRecords\": \"1\",\n" + + " \"Records\": [\n" + + " {\n" + + " \"RecordID\": \"1\",\n" + + " \"Results\": \"AC01,AC12,AE02,AV12,GE02\",\n" + + " \"FormattedAddress\": \"100 Main Street\",\n" + + " \"Organization\": \"\",\n" + + " \"AddressLine1\": \"100 Main Street\"\n" + + " }\n" + + " ]\n" + + "}"; + + private static final String RESPONSE_VALID_ADDRESS = "{\n" + + " \"Version\": \"3.0.1.160\",\n" + + " \"TransmissionReference\": \"1\",\n" + + " \"TransmissionResults\": \"\",\n" + + " \"TotalRecords\": \"1\",\n" + + " \"Records\": [\n" + + " {\n" + + " \"RecordID\": \"1\",\n" + + " \"Results\": \"AC01,AV24,GS05\",\n" + + " \"FormattedAddress\": \"100 Main St W;Hamilton ON L8P 1H6\"\n" + + " }\n" + + " ]\n" + + "}"; + + private static final String RESPONSE_INVALID_KEY = "{\n" + + " \"Version\": \"3.0.1.160\",\n" + + " \"TransmissionReference\": \"1\",\n" + + " \"TransmissionResults\": \"GE05\",\n" + + " \"TotalRecords\": \"0\"\n" + + "}"; + + private static FhirContext ourContext = FhirContext.forR4(); + + private MelissaAddressValidator myValidator; + + @BeforeEach + public void init() { + Properties props = new Properties(); + props.setProperty(MelissaAddressValidator.PROPERTY_SERVICE_KEY, "MY_KEY"); + myValidator = new MelissaAddressValidator(props); + + } + + @Test + public void testRequestBody() { + Map params = myValidator.getRequestParams(getAddress()); + + assertEquals("Line 1, Line 2", params.get("a1")); + assertEquals("City, POSTAL", params.get("a2")); + assertEquals("Country", params.get("ctry")); + assertEquals("MY_KEY", params.get("id")); + assertEquals("json", params.get("format")); + assertTrue(params.containsKey("t")); + } + + @Test + public void testServiceCalled() { + Address address = getAddress(); + + final RestTemplate template = mock(RestTemplate.class); + + Properties props = new Properties(); + props.setProperty(BaseRestfulValidator.PROPERTY_SERVICE_KEY, "MY_KEY"); + MelissaAddressValidator val = new MelissaAddressValidator(props) { + @Override + protected RestTemplate newTemplate() { + return template; + } + }; + + try { + val.getResponseEntity(address, ourContext); + } catch (Exception e) { + fail(); + } + + verify(template, times(1)).getForEntity(any(String.class), eq(String.class), any(Map.class)); + } + + private Address getAddress() { + Address address = new Address(); + address.addLine("Line 1").addLine("Line 2").setCity("City").setPostalCode("POSTAL").setCountry("Country"); + return address; + } + + @Test + public void testSuccessfulResponses() throws Exception { + AddressValidationResult res = myValidator.getValidationResult(new AddressValidationResult(), + new ObjectMapper().readTree(RESPONSE_INVALID_ADDRESS), ourContext); + assertFalse(res.isValid()); + + res = myValidator.getValidationResult(new AddressValidationResult(), + new ObjectMapper().readTree(RESPONSE_VALID_ADDRESS), ourContext); + assertTrue(res.isValid()); + assertEquals("100 Main St W;Hamilton ON L8P 1H6", res.getValidatedAddressString()); + } + + @Test + public void testErrorResponses() throws Exception { + assertThrows(AddressValidationException.class, () -> { + myValidator.getValidationResult(new AddressValidationResult(), + new ObjectMapper().readTree(RESPONSE_INVALID_KEY), ourContext); + }); + } + +} diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/FieldValidatingInterceptorTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/FieldValidatingInterceptorTest.java new file mode 100644 index 00000000000..533fc814777 --- /dev/null +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/FieldValidatingInterceptorTest.java @@ -0,0 +1,118 @@ +package ca.uhn.fhir.rest.server.interceptor.validation.fields; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import org.apache.commons.lang3.StringUtils; +import org.hl7.fhir.r4.model.ContactPoint; +import org.hl7.fhir.r4.model.Person; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static ca.uhn.fhir.rest.server.interceptor.s13n.StandardizingInterceptor.STANDARDIZATION_DISABLED_HEADER; +import static ca.uhn.fhir.rest.server.interceptor.validation.fields.FieldValidatingInterceptor.VALIDATION_DISABLED_HEADER; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class FieldValidatingInterceptorTest { + + private FhirContext myFhirContext = FhirContext.forR4(); + private FieldValidatingInterceptor myInterceptor = new FieldValidatingInterceptor(); + + public RequestDetails newRequestDetails() { + RequestDetails requestDetails = mock(RequestDetails.class); + when(requestDetails.getFhirContext()).thenReturn(myFhirContext); + return requestDetails; + } + + @BeforeEach + public void init() throws Exception { + myInterceptor = new FieldValidatingInterceptor(); + } + + @Test + public void testDisablingValidationViaHeader() { + RequestDetails request = newRequestDetails(); + when(request.getHeaders(eq(VALIDATION_DISABLED_HEADER))).thenReturn(Arrays.asList(new String[]{"True"})); + + Person person = new Person(); + person.addTelecom().setSystem(ContactPoint.ContactPointSystem.EMAIL).setValue("EMAIL"); + + myInterceptor.handleRequest(request, person); + assertEquals("EMAIL", person.getTelecom().get(0).getValue()); + } + + @Test + public void testEmailValidation() { + Person person = new Person(); + person.addTelecom().setSystem(ContactPoint.ContactPointSystem.EMAIL).setValue("email@email.com"); + + try { + myInterceptor.handleRequest(newRequestDetails(), person); + } catch (Exception e) { + fail(); + } + } + + @Test + public void testInvalidEmailValidation() { + Person person = new Person(); + person.addTelecom().setSystem(ContactPoint.ContactPointSystem.EMAIL).setValue("@garbage"); + + try { + myInterceptor.handleRequest(newRequestDetails(), person); + fail(); + } catch (Exception e) { + } + } + + @Test + public void testCustomInvalidValidation() { + myInterceptor.getConfig().put("telecom.where(system='phone').value", "ClassThatDoesntExist"); + try { + myInterceptor.handleRequest(newRequestDetails(), new Person()); + fail(); + } catch (Exception e) { + } + } + + @Test + public void testCustomValidation() { + myInterceptor.getConfig().put("telecom.where(system='phone').value", EmptyValidator.class.getName()); + + Person person = new Person(); + person.addTelecom().setSystem(ContactPoint.ContactPointSystem.EMAIL).setValue("email@email.com"); + + try { + myInterceptor.handleRequest(newRequestDetails(), person); + } catch (Exception e) { + fail(); + } + + person.addTelecom().setSystem(ContactPoint.ContactPointSystem.PHONE).setValue("123456"); + try { + myInterceptor.handleRequest(newRequestDetails(), person); + } catch (Exception e) { + fail(); + } + + person = new Person(); + person.addTelecom().setSystem(ContactPoint.ContactPointSystem.PHONE).setValue(" "); + try { + myInterceptor.handleRequest(newRequestDetails(), person); + fail(); + } catch (Exception e) { + } + } + + public static class EmptyValidator implements IValidator { + @Override + public boolean isValid(String theString) { + return !StringUtils.isBlank(theString); + } + } + +} diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ExtensionUtilTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ExtensionUtilTest.java new file mode 100644 index 00000000000..8e582852128 --- /dev/null +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ExtensionUtilTest.java @@ -0,0 +1,38 @@ +package ca.uhn.fhir.util; + +import ca.uhn.fhir.context.FhirContext; +import org.hl7.fhir.instance.model.api.IBaseDatatype; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.*; + +class ExtensionUtilTest { + + private static final String EXT_URL = "http://magic.com/extensions"; + + private static FhirContext ourFhirContext = FhirContext.forR4(); + + @Test + void testExtensionsWork() { + Patient p1 = new Patient(); + assertFalse(ExtensionUtil.hasExtension(p1, EXT_URL)); + ExtensionUtil.setExtension(ourFhirContext, p1, EXT_URL, "value"); + assertTrue(ExtensionUtil.hasExtension(p1, EXT_URL)); + } + + @Test + void testExtensionTypesWork() { + Patient p1 = new Patient(); + assertFalse(ExtensionUtil.hasExtension(p1, EXT_URL)); + ExtensionUtil.setExtension(ourFhirContext, p1, EXT_URL, "integer", "1"); + + assertTrue(ExtensionUtil.hasExtension(p1, EXT_URL)); + assertEquals(1, ExtensionUtil.getExtensions(p1, EXT_URL).size()); + + IBaseDatatype ext = ExtensionUtil.getExtension(p1, EXT_URL).getValue(); + assertEquals("1", ext.toString()); + } +} diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/PropertyModifyingHelperTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/PropertyModifyingHelperTest.java new file mode 100644 index 00000000000..6d212ad8066 --- /dev/null +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/PropertyModifyingHelperTest.java @@ -0,0 +1,37 @@ +package ca.uhn.fhir.util; + +import ca.uhn.fhir.context.FhirContext; +import org.hl7.fhir.r4.model.Address; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class PropertyModifyingHelperTest { + + private static FhirContext ourContext = FhirContext.forR4(); + + @Test + public void testSetAndGet() { + Address address = new Address(); + + PropertyModifyingHelper helper = new PropertyModifyingHelper(ourContext, address); + helper.set("line", "line1"); + helper.set("line", "line2"); + helper.set("city", "city"); + + address = (Address) helper.getBase(); + + assertEquals(2, address.getLine().size()); + assertEquals("city", address.getCity()); + assertNull(address.getCountry()); + + helper.setDelimiter(";"); + assertEquals("line1;line2;city", helper.getFields("line", "city")); + List lines = helper.getMultiple("line"); + assertEquals("[line1, line2]", lines.toString()); + } + +} diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/TerserUtilTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/TerserUtilTest.java new file mode 100644 index 00000000000..52826cfc893 --- /dev/null +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/TerserUtilTest.java @@ -0,0 +1,240 @@ +package ca.uhn.fhir.util; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.RuntimeResourceDefinition; +import org.hl7.fhir.r4.model.DateTimeType; +import org.hl7.fhir.r4.model.DateType; +import org.hl7.fhir.r4.model.Enumerations; +import org.hl7.fhir.r4.model.Extension; +import org.hl7.fhir.r4.model.Identifier; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.Test; + +import java.util.GregorianCalendar; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TerserUtilTest { + + private FhirContext ourFhirContext = FhirContext.forR4(); + + @Test + void testCloneEidIntoResource() { + Identifier identifier = new Identifier().setSystem("http://org.com/sys").setValue("123"); + + Patient p1 = new Patient(); + p1.addIdentifier(identifier); + + Patient p2 = new Patient(); + RuntimeResourceDefinition definition = ourFhirContext.getResourceDefinition(p1); + TerserUtil.cloneEidIntoResource(ourFhirContext, definition.getChildByName("identifier"), identifier, p2); + + assertEquals(1, p2.getIdentifier().size()); + assertEquals(p1.getIdentifier().get(0).getSystem(), p2.getIdentifier().get(0).getSystem()); + assertEquals(p1.getIdentifier().get(0).getValue(), p2.getIdentifier().get(0).getValue()); + } + + @Test + void testCloneEidIntoResourceViaHelper() { + TerserUtilHelper p1Helper = TerserUtilHelper.newHelper(ourFhirContext, "Patient"); + p1Helper.setField("identifier.system", "http://org.com/sys"); + p1Helper.setField("identifier.value", "123"); + + Patient p1 = p1Helper.getResource(); + assertEquals(1, p1.getIdentifier().size()); + + TerserUtilHelper p2Helper = TerserUtilHelper.newHelper(ourFhirContext, "Patient"); + RuntimeResourceDefinition definition = p1Helper.getResourceDefinition(); + + TerserUtil.cloneEidIntoResource(ourFhirContext, definition.getChildByName("identifier"), + p1.getIdentifier().get(0), p2Helper.getResource()); + + assertEquals(1, p2Helper.getFieldValues("identifier").size()); + + Identifier id1 = (Identifier) p1Helper.getFieldValues("identifier").get(0); + Identifier id2 = (Identifier) p2Helper.getFieldValues("identifier").get(0); + assertTrue(id1.equalsDeep(id2)); + assertFalse(id1.equals(id2)); + } + + @Test + void testSetFieldsViaHelper() { + TerserUtilHelper p1Helper = TerserUtilHelper.newHelper(ourFhirContext, "Patient"); + p1Helper.setField("active", "boolean", "true"); + p1Helper.setField("birthDate", "date", "1999-01-01"); + p1Helper.setField("gender", "code", "male"); + + Patient p = p1Helper.getResource(); + assertTrue(p.getActive()); + assertEquals(Enumerations.AdministrativeGender.MALE, p.getGender()); + + DateType check = TerserUtil.newElement(ourFhirContext, "date", "1999-01-01"); + assertEquals(check.getValue(), p.getBirthDate()); + } + + @Test + void testFieldExists() { + assertTrue(TerserUtil.fieldExists(ourFhirContext, "identifier", TerserUtil.newResource(ourFhirContext, "Patient"))); + assertFalse(TerserUtil.fieldExists(ourFhirContext, "randomFieldName", TerserUtil.newResource(ourFhirContext, "Patient"))); + } + + @Test + void testCloneFields() { + Patient p1 = new Patient(); + p1.addName().addGiven("Sigizmund"); + + Patient p2 = new Patient(); + + TerserUtil.mergeFieldsExceptIdAndMeta(ourFhirContext, p1, p2); + + assertTrue(p2.getIdentifier().isEmpty()); + + assertNull(p2.getId()); + assertEquals(1, p2.getName().size()); + assertEquals(p1.getName().get(0).getNameAsSingleString(), p2.getName().get(0).getNameAsSingleString()); + } + + @Test + void testCloneWithNonPrimitves() { + Patient p1 = new Patient(); + Patient p2 = new Patient(); + + p1.addName().addGiven("Joe"); + p1.getNameFirstRep().addGiven("George"); + assertThat(p1.getName(), hasSize(1)); + assertThat(p1.getName().get(0).getGiven(), hasSize(2)); + + p2.addName().addGiven("Jeff"); + p2.getNameFirstRep().addGiven("George"); + assertThat(p2.getName(), hasSize(1)); + assertThat(p2.getName().get(0).getGiven(), hasSize(2)); + + TerserUtil.mergeAllFields(ourFhirContext, p1, p2); + assertThat(p2.getName(), hasSize(2)); + assertThat(p2.getName().get(0).getGiven(), hasSize(2)); + assertThat(p2.getName().get(1).getGiven(), hasSize(2)); + } + + @Test + void testMergeForAddressWithExtensions() { + Extension ext = new Extension(); + ext.setUrl("http://hapifhir.io/extensions/address#create-timestamp"); + ext.setValue(new DateTimeType("2021-01-02T11:13:15")); + + Patient p1 = new Patient(); + p1.addAddress() + .addLine("10 Main Street") + .setCity("Hamilton") + .setState("ON") + .setPostalCode("Z0Z0Z0") + .setCountry("Canada") + .addExtension(ext); + + Patient p2 = new Patient(); + p2.addAddress().addLine("10 Lenin Street").setCity("Severodvinsk").setCountry("Russia"); + + TerserUtil.mergeField(ourFhirContext, "address", p1, p2); + + assertEquals(2, p2.getAddress().size()); + assertEquals("[10 Lenin Street]", p2.getAddress().get(0).getLine().toString()); + assertEquals("[10 Main Street]", p2.getAddress().get(1).getLine().toString()); + assertTrue(p2.getAddress().get(1).hasExtension()); + + p1 = new Patient(); + p1.addAddress().addLine("10 Main Street").addExtension(ext); + p2 = new Patient(); + p2.addAddress().addLine("10 Main Street").addExtension(new Extension("demo", new DateTimeType("2021-01-02"))); + + TerserUtil.mergeField(ourFhirContext, "address", p1, p2); + assertEquals(2, p2.getAddress().size()); + assertTrue(p2.getAddress().get(0).hasExtension()); + assertTrue(p2.getAddress().get(1).hasExtension()); + + } + + @Test + void testReplaceForAddressWithExtensions() { + Extension ext = new Extension(); + ext.setUrl("http://hapifhir.io/extensions/address#create-timestamp"); + ext.setValue(new DateTimeType("2021-01-02T11:13:15")); + + Patient p1 = new Patient(); + p1.addAddress() + .addLine("10 Main Street") + .setCity("Hamilton") + .setState("ON") + .setPostalCode("Z0Z0Z0") + .setCountry("Canada") + .addExtension(ext); + + Patient p2 = new Patient(); + p2.addAddress().addLine("10 Lenin Street").setCity("Severodvinsk").setCountry("Russia"); + + TerserUtil.replaceField(ourFhirContext, "address", p1, p2); + + assertEquals(1, p2.getAddress().size()); + assertEquals("[10 Main Street]", p2.getAddress().get(0).getLine().toString()); + assertTrue(p2.getAddress().get(0).hasExtension()); + } + + @Test + void testMergeForSimilarAddresses() { + Extension ext = new Extension(); + ext.setUrl("http://hapifhir.io/extensions/address#create-timestamp"); + ext.setValue(new DateTimeType("2021-01-02T11:13:15")); + + Patient p1 = new Patient(); + p1.addAddress() + .addLine("10 Main Street") + .setCity("Hamilton") + .setState("ON") + .setPostalCode("Z0Z0Z0") + .setCountry("Canada") + .addExtension(ext); + + Patient p2 = new Patient(); + p2.addAddress() + .addLine("10 Main Street") + .setCity("Hamilton") + .setState("ON") + .setPostalCode("Z0Z0Z1") + .setCountry("Canada") + .addExtension(ext); + + TerserUtil.mergeField(ourFhirContext, "address", p1, p2); + + assertEquals(2, p2.getAddress().size()); + assertEquals("[10 Main Street]", p2.getAddress().get(0).getLine().toString()); + assertEquals("[10 Main Street]", p2.getAddress().get(1).getLine().toString()); + assertTrue(p2.getAddress().get(1).hasExtension()); + } + + + @Test + void testCloneWithDuplicateNonPrimitives() { + Patient p1 = new Patient(); + Patient p2 = new Patient(); + + p1.addName().addGiven("Jim"); + p1.getNameFirstRep().addGiven("George"); + + assertThat(p1.getName(), hasSize(1)); + assertThat(p1.getName().get(0).getGiven(), hasSize(2)); + + p2.addName().addGiven("Jim"); + p2.getNameFirstRep().addGiven("George"); + + assertThat(p2.getName(), hasSize(1)); + assertThat(p2.getName().get(0).getGiven(), hasSize(2)); + + TerserUtil.mergeAllFields(ourFhirContext, p1, p2); + + assertThat(p2.getName(), hasSize(1)); + assertThat(p2.getName().get(0).getGiven(), hasSize(2)); + } +}