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..e9268f8fba8
--- /dev/null
+++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/PrimitiveTypeEqualsPredicate.java
@@ -0,0 +1,71 @@
+package ca.uhn.fhir.util;
+
+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/TerserUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtil.java
new file mode 100644
index 00000000000..f02cee69a54
--- /dev/null
+++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtil.java
@@ -0,0 +1,336 @@
+package ca.uhn.fhir.util;
+
+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 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
+ */
+ public static void cloneCompositeField(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo, String field) {
+ FhirTerser terser = theFhirContext.newTerser();
+
+ 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));
+ }
+
+ /**
+ * Clears the specified field on the resource provided
+ *
+ * @param theFhirContext
+ * @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
+ * @param theTerser
+ * @param theFieldName
+ * @param theResource
+ * @param theValues
+ */
+ 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);
+ }
+
+ 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
+ */
+ 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
+ * @param theTerser
+ * @param theFieldName
+ * @param theFrom
+ * @param theTo
+ */
+ 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;
+ }
+ }
+ }
+
+ 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;
+ }
+
+ public static T newElement(FhirContext theFhirContext, String theElementType) {
+ BaseRuntimeElementDefinition def = theFhirContext.getElementDefinition(theElementType);
+ return (T) def.newInstance();
+ }
+
+ public static T newElement(FhirContext theFhirContext, String theElementType, Object theConstructorParam) {
+ BaseRuntimeElementDefinition def = theFhirContext.getElementDefinition(theElementType);
+ return (T) def.newInstance(theConstructorParam);
+ }
+
+ public static T newResource(FhirContext theFhirContext, String theResourceName) {
+ RuntimeResourceDefinition def = theFhirContext.getResourceDefinition(theResourceName);
+ return (T) def.newInstance();
+ }
+
+ 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..c245f5fb38c
--- /dev/null
+++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtilHelper.java
@@ -0,0 +1,73 @@
+package ca.uhn.fhir.util;
+
+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.
+ */
+public class TerserUtilHelper {
+
+ public static TerserUtilHelper newHelper(FhirContext theFhirContext, String theResource) {
+ return newHelper(theFhirContext, (IBaseResource) TerserUtil.newResource(theFhirContext, theResource));
+ }
+
+ public static TerserUtilHelper newHelper(FhirContext theFhirContext, IBaseResource theResource) {
+ TerserUtilHelper retVal = new TerserUtilHelper(theFhirContext, theResource);
+ return retVal;
+ }
+
+ FhirContext myContext;
+ FhirTerser myTerser;
+ IBaseResource myResource;
+
+ protected TerserUtilHelper(FhirContext theFhirContext, IBaseResource theResource) {
+ myContext = theFhirContext;
+ myResource = theResource;
+ }
+
+ public TerserUtilHelper setField(String theField, String theValue) {
+ IBase value = TerserUtil.newElement(myContext, "string", theValue);
+ TerserUtil.setField(myContext, getTerser(), theField, myResource, value);
+ return this;
+ }
+
+ public List getFieldValues(String theField) {
+ return TerserUtil.getValues(myContext, myResource, theField);
+ }
+
+ 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);
+ }
+
+ public FhirContext getContext() {
+ return myContext;
+ }
+
+}
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..084ea8c14ea
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/ConfigLoader.java
@@ -0,0 +1,50 @@
+package ca.uhn.fhir.rest.server.interceptor;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.commons.io.IOUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.util.ResourceUtils;
+
+import java.io.File;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.StringReader;
+import java.net.URL;
+import java.util.Properties;
+
+public class ConfigLoader {
+
+ private static final Logger ourLog = LoggerFactory.getLogger(ConfigLoader.class);
+
+ public static String loadResourceContent(String theResourcePath) {
+ try {
+ URL url = ResourceUtils.getURL(theResourcePath);
+ File file = ResourceUtils.getFile(url);
+ return IOUtils.toString(new FileReader(file));
+ } catch (Exception e) {
+ throw new RuntimeException(String.format("Unable to load resource %s", theResourcePath), e);
+ }
+ }
+
+ 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..1b796ff4b30
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/StandardizingInterceptor.java
@@ -0,0 +1,167 @@
+package ca.uhn.fhir.rest.server.interceptor.s13n;
+
+import ca.uhn.fhir.context.FhirContext;
+import ca.uhn.fhir.fhirpath.FhirPathExecutionException;
+import ca.uhn.fhir.fhirpath.IFhirPath;
+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.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;
+
+public class StandardizingInterceptor extends ServerOperationInterceptorAdapter {
+
+ /**
+ * Pre-defined standardizers
+ */
+ public enum StandardizationType {
+ NAME_FAMILY, NAME_GIVEN, EMAIL, TITLE, PHONE, TEXT;
+ }
+
+ public static final String STANDARDIZATION_DISABLED_HEADER = "CDR-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);
+ }
+
+ @Override
+ public void resourcePreCreate(RequestDetails theRequest, IBaseResource theResource) {
+ ourLog.debug("Standardizing on pre-create for - {}, {}", theRequest, theResource);
+ standardize(theRequest, theResource);
+ }
+
+ @Override
+ 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("*") || 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..c905d22e7dd
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/EmailStandardizer.java
@@ -0,0 +1,25 @@
+package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers;
+
+/**
+ * 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..6eb07b135ad
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/FirstNameStandardizer.java
@@ -0,0 +1,127 @@
+package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers;
+
+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..6bbcfb99359
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/IStandardizer.java
@@ -0,0 +1,16 @@
+package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers;
+
+/**
+ * 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..7bafa174945
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/LastNameStandardizer.java
@@ -0,0 +1,61 @@
+package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers;
+
+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..a55f7267903
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/NoiseCharacters.java
@@ -0,0 +1,99 @@
+package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers;
+
+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..dd77ce01b03
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/PhoneStandardizer.java
@@ -0,0 +1,22 @@
+package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers;
+
+/**
+ * Standardizes phone numbers to fit 123-456-7890 patter.
+ */
+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..91a48d88766
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/Range.java
@@ -0,0 +1,50 @@
+package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers;
+
+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..ba499ed19dc
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/TextStandardizer.java
@@ -0,0 +1,146 @@
+package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers;
+
+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..967888aa583
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/s13n/standardizers/TitleStandardizer.java
@@ -0,0 +1,137 @@
+package ca.uhn.fhir.rest.server.interceptor.s13n.standardizers;
+
+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..521b5f869b6
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidatingInterceptor.java
@@ -0,0 +1,144 @@
+package ca.uhn.fhir.rest.server.interceptor.validation.address;
+
+import ca.uhn.fhir.context.BaseRuntimeChildDefinition;
+import ca.uhn.fhir.context.FhirContext;
+import ca.uhn.fhir.context.RuntimeResourceDefinition;
+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.helpers.ExtensionHelper;
+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;
+
+public class AddressValidatingInterceptor extends ServerOperationInterceptorAdapter {
+
+ 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 = "CDR-Address-Validation-Disabled";
+
+ private ExtensionHelper myExtensionHelper = new ExtensionHelper();
+
+ private IAddressValidator myAddressValidator;
+
+ private Properties myProperties;
+
+
+ public AddressValidatingInterceptor() {
+ super();
+
+ ourLog.info("Starting AddressValidatingInterceptor {}", this);
+ myProperties = ConfigLoader.loadProperties("classpath:address-validation.properties");
+ start(myProperties);
+ }
+
+ 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);
+ }
+ }
+
+ @Override
+ public void resourcePreCreate(RequestDetails theRequest, IBaseResource theResource) {
+ ourLog.debug("Validating address on for create {}, {}", theResource, theRequest);
+ handleRequest(theRequest, theResource);
+ }
+
+ @Override
+ 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 !myExtensionHelper.hasExtension(a, IAddressValidator.ADDRESS_VALIDATION_EXTENSION_URL) ||
+ myExtensionHelper.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);
+ myExtensionHelper.setValue(theAddress, IAddressValidator.ADDRESS_VALIDATION_EXTENSION_URL,
+ validationResult.isValid() ? IAddressValidator.EXT_VALUE_VALID : IAddressValidator.EXT_VALUE_INVALID, theFhirContext);
+ } catch (Exception ex) {
+ myExtensionHelper.setValue(theAddress, IAddressValidator.ADDRESS_VALIDATION_EXTENSION_URL, IAddressValidator.EXT_UNABLE_TO_VALIDATE, theFhirContext);
+ }
+ }
+
+ 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..b29c3c582b8
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidationException.java
@@ -0,0 +1,19 @@
+package ca.uhn.fhir.rest.server.interceptor.validation.address;
+
+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..eec8f09396c
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidationResult.java
@@ -0,0 +1,55 @@
+package ca.uhn.fhir.rest.server.interceptor.validation.address;
+
+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;
+ }
+}
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..28fee849062
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/IAddressValidator.java
@@ -0,0 +1,41 @@
+package ca.uhn.fhir.rest.server.interceptor.validation.address;
+
+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..a4b6b9b1799
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/BaseRestfulValidator.java
@@ -0,0 +1,80 @@
+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.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..d9b4f9d825e
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/LoquateAddressValidator.java
@@ -0,0 +1,185 @@
+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 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());
+ theResult.setValidatedAddressString(theMatch.get("Address").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(addressBase, theFhirContext);
+ 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) {
+ theAddressLine = theAddressLine.replaceAll(theMatch.get(s).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(theAddress, theFhirContext);
+ 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..150985f2cab
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/MelissaAddressValidator.java
@@ -0,0 +1,119 @@
+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 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(theAddress, null);
+
+ 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..9253170ddd5
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/EmailValidator.java
@@ -0,0 +1,14 @@
+package ca.uhn.fhir.rest.server.interceptor.validation.fields;
+
+import java.util.regex.Pattern;
+
+public class EmailValidator implements IValidator {
+
+ private Pattern myPattern = Pattern.compile("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$",
+ Pattern.CASE_INSENSITIVE);
+
+ @Override
+ public boolean isValid(String theString) {
+ return myPattern.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..4e9d74c3701
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/FieldValidatingInterceptor.java
@@ -0,0 +1,90 @@
+package ca.uhn.fhir.rest.server.interceptor.validation.fields;
+
+import ca.uhn.fhir.context.FhirContext;
+import ca.uhn.fhir.fhirpath.IFhirPath;
+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;
+
+public class FieldValidatingInterceptor extends ServerOperationInterceptorAdapter {
+
+ public enum ValidatorType {
+ EMAIL;
+ }
+
+ private static final Logger ourLog = LoggerFactory.getLogger(FieldValidatingInterceptor.class);
+
+ public static final String VALIDATION_DISABLED_HEADER = "CDR-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);
+ }
+
+ @Override
+ public void resourcePreCreate(RequestDetails theRequest, IBaseResource theResource) {
+ ourLog.debug("Validating address on for create {}, {}", theResource, theRequest);
+ handleRequest(theRequest, theResource);
+ }
+
+ @Override
+ 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 (!theRequest.getHeaders(VALIDATION_DISABLED_HEADER).isEmpty()) {
+ ourLog.debug("Address validation is disabled for this request via header");
+ }
+
+ 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..1ce1bb3ff47
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/IValidator.java
@@ -0,0 +1,7 @@
+package ca.uhn.fhir.rest.server.interceptor.validation.fields;
+
+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..9ff1a24662f
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/helpers/AddressHelper.java
@@ -0,0 +1,102 @@
+package ca.uhn.fhir.rest.server.interceptor.validation.helpers;
+
+import ca.uhn.fhir.context.FhirContext;
+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 BaseHelper {
+
+ 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(IBase theBase, FhirContext theFhirContext) {
+ super(theBase, theFhirContext);
+ }
+
+ 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/java/ca/uhn/fhir/rest/server/interceptor/validation/helpers/BaseHelper.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/helpers/BaseHelper.java
new file mode 100644
index 00000000000..d880da028f4
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/helpers/BaseHelper.java
@@ -0,0 +1,133 @@
+package ca.uhn.fhir.rest.server.interceptor.validation.helpers;
+
+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;
+
+public class BaseHelper {
+
+ 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;
+
+ public BaseHelper(IBase theBase, FhirContext theFhirContext) {
+ if (findGetPropertyMethod(theBase) == null) {
+ throw new IllegalArgumentException("Specified base instance does not support property retrieval.");
+ }
+ myBase = theBase;
+ myFhirContext = theFhirContext;
+ }
+
+ 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;
+ }
+
+ 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 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 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);
+ }
+
+ public String getDelimiter() {
+ return myDelimiter;
+ }
+
+ public void setDelimiter(String theDelimiter) {
+ this.myDelimiter = theDelimiter;
+ }
+
+ public IBase getBase() {
+ return myBase;
+ }
+}
diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/helpers/ExtensionHelper.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/helpers/ExtensionHelper.java
new file mode 100644
index 00000000000..f83b9ccbcc6
--- /dev/null
+++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/validation/helpers/ExtensionHelper.java
@@ -0,0 +1,95 @@
+package ca.uhn.fhir.rest.server.interceptor.validation.helpers;
+
+import ca.uhn.fhir.context.BaseRuntimeElementDefinition;
+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;
+
+public class ExtensionHelper {
+
+ /**
+ * 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 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 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 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 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);
+ }
+
+ private IBaseExtension, ?> getExtension(IBaseHasExtensions theBase, String theExtensionUrl) {
+ return theBase.getExtension()
+ .stream()
+ .filter(e -> theExtensionUrl.equals(e.getUrl()))
+ .findFirst()
+ .orElse(null);
+ }
+
+ public void setValue(IBaseExtension theExtension, String theValue, FhirContext theFhirContext) {
+ theExtension.setValue(newString(theValue, theFhirContext));
+ }
+
+ public void setValue(IBase theBase, String theUrl, String theValue, FhirContext theFhirContext) {
+ IBaseExtension ext = getOrCreateExtension(theBase, theUrl);
+ setValue(ext, theValue, theFhirContext);
+ }
+
+ public IBaseDatatype newString(String theValue, FhirContext theFhirContext) {
+ BaseRuntimeElementDefinition> def = theFhirContext.getElementDefinition("string");
+ return (IBaseDatatype) def.newInstance(theValue);
+ }
+
+}
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/validation/address/impl/BaseRestfulValidatorTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/BaseRestfulValidatorTest.java
new file mode 100644
index 00000000000..214510f46f6
--- /dev/null
+++ b/hapi-fhir-server/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-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/LoquateAddressValidatorTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/LoquateAddressValidatorTest.java
new file mode 100644
index 00000000000..3fe9ad0f7ea
--- /dev/null
+++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/LoquateAddressValidatorTest.java
@@ -0,0 +1,144 @@
+package ca.uhn.fhir.rest.server.interceptor.validation.address.impl;
+
+import ca.uhn.fhir.context.FhirContext;
+import com.fasterxml.jackson.core.JsonProcessingException;
+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.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 javax.validation.constraints.NotNull;
+import java.util.Properties;
+
+import static org.junit.jupiter.api.Assertions.*;
+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_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 testRequestBody() {
+// try {
+// assertEquals(REQUEST, myValidator.getRequestBody(ourCtx, getAddress()));
+// } catch (JsonProcessingException e) {
+// fail();
+// }
+// }
+//
+// @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));
+// }
+//
+// @NotNull
+// 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-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/MelissaAddressValidatorTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/MelissaAddressValidatorTest.java
new file mode 100644
index 00000000000..c347c78c882
--- /dev/null
+++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/MelissaAddressValidatorTest.java
@@ -0,0 +1,140 @@
+package ca.uhn.fhir.rest.server.interceptor.validation.address.impl;
+
+import ca.uhn.fhir.context.FhirContext;
+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.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));
+// }
+//
+// @NotNull
+// 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-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..f6641687343
--- /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(name, null);
+ assertThrows(IllegalStateException.class, () -> {
+ helper.getCountry();
+ });
+
+ assertThrows(IllegalArgumentException.class, () -> {
+ new AddressHelper(new StringType("this will blow up"), null);
+ });
+ }
+
+ @Test
+ void getCountry() {
+ Address a = new Address();
+ a.setCountry("Test");
+
+ AddressHelper helper = new AddressHelper(a, null);
+ assertEquals("Test", helper.getCountry());
+ }
+
+ @Test
+ void getParts() {
+ Address a = new Address();
+ a.setCity("Hammer");
+
+ AddressHelper helper = new AddressHelper(a, null);
+ 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(a, null);
+ 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(a, ourContext);
+ 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..df839f0606a
--- /dev/null
+++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/s13n/interceptors/StandardizingInterceptorTest.java
@@ -0,0 +1,93 @@
+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 java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+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\"*\" : {\n" +
+ "\t\t\"telecom.where(system='email').value\" : \"EMAIL\"\n" +
+ "\t},\n" +
+ "\t\"Patient\" : {\n" +
+ "\t\t\"name.given\" : \"NAME_GIVEN\",\n" +
+ "\t\t\"telecom.where(system='phone').value\" : \"PHONE\"\n" +
+ "\t\t},\n" +
+ "\t\"*\" : {\n" +
+ "\t\t\"telecom.where(system='email').value\" : \"EMAIL\"\n" +
+ "\t}\n" +
+ "\n" +
+ "}";
+
+ 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 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());
+ assertEquals("123-456-7890", p.getTelecom().get(1).getValue());
+ }
+
+ @Test
+ public void testUniversalOtherTypes() throws Exception {
+ Patient p = new Patient();
+ p.addName().setFamily("FAM").addGiven("GIV");
+ p.addTelecom().setSystem(ContactPoint.ContactPointSystem.EMAIL).setValue(" Test@TEST.com ");
+
+ myInterceptor.resourcePreUpdate(myRequestDetails, null, p);
+
+ assertEquals("Giv FAM", p.getName().get(0).getNameAsSingleString());
+ assertEquals("test@test.com", p.getTelecom().get(0).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..2f748664e89
--- /dev/null
+++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/AddressValidatingInterceptorTest.java
@@ -0,0 +1,131 @@
+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.hl7.fhir.dstu3.model.Address;
+//import org.hl7.fhir.dstu3.model.Person;
+//import org.hl7.fhir.dstu3.model.StringType;
+import org.hl7.fhir.instance.model.api.IBase;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Properties;
+
+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.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.forDstu3();
+//
+// private AddressValidatingInterceptor myInterceptor;
+//
+// private IAddressValidator myValidator;
+//
+// private RequestDetails myRequestDetails;
+//
+// @Test
+// void start() throws Exception {
+// AddressValidatingInterceptor interceptor = new AddressValidatingInterceptor();
+// interceptor.start(new Properties());
+// assertNull(interceptor.getAddressValidator());
+//
+// Properties props = new Properties();
+// props.setProperty(AddressValidatingInterceptor.PROPERTY_VALIDATOR_CLASS, "RandomService");
+// interceptor.setProperties(props);
+// try {
+// interceptor.start();
+// fail();
+// } catch (Exception e) {
+// // expected
+// }
+//
+// props.setProperty(AddressValidatingInterceptor.PROPERTY_VALIDATOR_CLASS, TestAddressValidator.class.getName());
+// interceptor = new AddressValidatingInterceptor();
+// interceptor.setProperties(props);
+//
+// interceptor.start();
+// 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);
+//
+// myInterceptor = new AddressValidatingInterceptor();
+// myInterceptor.setAddressValidator(myValidator);
+// }
+//
+// @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..214510f46f6
--- /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..3fe9ad0f7ea
--- /dev/null
+++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/LoquateAddressValidatorTest.java
@@ -0,0 +1,144 @@
+package ca.uhn.fhir.rest.server.interceptor.validation.address.impl;
+
+import ca.uhn.fhir.context.FhirContext;
+import com.fasterxml.jackson.core.JsonProcessingException;
+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.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 javax.validation.constraints.NotNull;
+import java.util.Properties;
+
+import static org.junit.jupiter.api.Assertions.*;
+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_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 testRequestBody() {
+// try {
+// assertEquals(REQUEST, myValidator.getRequestBody(ourCtx, getAddress()));
+// } catch (JsonProcessingException e) {
+// fail();
+// }
+// }
+//
+// @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));
+// }
+//
+// @NotNull
+// 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..c347c78c882
--- /dev/null
+++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/address/impl/MelissaAddressValidatorTest.java
@@ -0,0 +1,140 @@
+package ca.uhn.fhir.rest.server.interceptor.validation.address.impl;
+
+import ca.uhn.fhir.context.FhirContext;
+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.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));
+// }
+//
+// @NotNull
+// 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..1d8bae47853
--- /dev/null
+++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/validation/fields/FieldValidatingInterceptorTest.java
@@ -0,0 +1,91 @@
+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 static org.junit.jupiter.api.Assertions.*;
+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 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 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/TerserUtilTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/TerserUtilTest.java
new file mode 100644
index 00000000000..9a84c04d76a
--- /dev/null
+++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/TerserUtilTest.java
@@ -0,0 +1,216 @@
+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.Extension;
+import org.hl7.fhir.r4.model.Identifier;
+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.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 idHelper = TerserUtilHelper.newHelper(ourFhirContext, "Identifier");
+ idHelper
+ .setField("system", "http://org.com/sys")
+ .setField("value", "123");
+
+ TerserUtilHelper p1Helper = TerserUtilHelper.newHelper(ourFhirContext, "Patient");
+ p1Helper.setField("identifier", idHelper.getResource());
+
+ TerserUtilHelper p2Helper = TerserUtilHelper.newHelper(ourFhirContext, "Patient");
+ RuntimeResourceDefinition definition = p1Helper.getResourceDefinition();
+ TerserUtil.cloneEidIntoResource(ourFhirContext, definition.getChildByName("identifier"), idHelper.getResource(), p2Helper.getResource());
+
+ assertEquals(1, p2Helper.getFieldValues("identifier").size());
+ assertEquals(p1Helper.getFieldValues("identifier").get(0), p2Helper.getFieldValues("identifier").get(0));
+ }
+
+ @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));
+ }
+}