type() {
- return HumanName.class;
- }
-
- @Override
- public HumanName fromString(String name) {
- HumanName hn = new HumanName();
- hn.setText(name);
- String[] parts = name.split("\\s");
- boolean hasGiven = false;
- boolean hasPrefix = false;
- boolean hasSuffix = false;
- StringBuilder familyName = new StringBuilder();
- for (String part : parts) {
- if (!hasGiven && !hasPrefix && isPrefix(part)) {
- hn.addPrefix(part);
- hasPrefix = true;
- } else if (!hasGiven && (hasPrefix || !isPrefix(part))) {
- hn.addGiven(part);
- hasGiven = true;
- hasPrefix = true;
- } else if (hasGiven && familyName.isEmpty() && isAffix(part)) {
- familyName.append(part);
- } else if (!familyName.isEmpty() && (isSuffix(part) || isDegree(part))) {
- hn.addSuffix(part);
- hasSuffix = true;
- } else if (hasSuffix) {
- hn.addSuffix(part);
- }
- }
- if (!familyName.isEmpty()) {
- hn.setFamily(familyName.toString());
- }
- if (hn.isEmpty()) {
- return null;
- }
- return hn;
- }
-
- @Override
- public HumanName convert(Type t) {
- if (t instanceof Varies v) {
- t = v.getData();
- }
- if (t instanceof Primitive pt) {
- return fromString(pt.getValue());
- }
-
- if (t instanceof Composite comp) {
- Type[] types = comp.getComponents();
- switch (t.getName()) {
- case "CNN" :
- return HumanNameParser.parse(types, 1, -1);
- case "XCN" :
- return HumanNameParser.parse(types, 1, 9);
- case "XPN" :
- return HumanNameParser.parse(types, 0, 9);
- default :
- break;
- }
- }
- return null;
- }
-
-
- static HumanName parse(Type[] types, int offset, int nameTypeLoc) {
- HumanName hn = new HumanName();
- for (int i = 0; i < 6; i++) {
- String part = ParserUtils.toString(types[i + offset]);
- if (StringUtils.isBlank(part)) {
- continue;
- }
- switch (i) {
- case 0 : // Family Name (as ST in HL7 2.3)
- hn.setFamily(part);
- break;
- case 1 : // Given Name
- hn.addGiven(part);
- break;
- case 2 : // Second and Further Given Name or Initials
- // Thereof
- hn.addGiven(part);
- break;
- case 3 : // Suffix
- hn.addSuffix(part);
- break;
- case 4 : // Prefix
- hn.addPrefix(part);
- break;
- case 5 : // Degree
- hn.addSuffix(part);
- break;
- default :
- break;
- }
- }
- if (nameTypeLoc >= 0 && nameTypeLoc < types.length
- && types[nameTypeLoc] instanceof Primitive pt) {
- String nameType = pt.getValue();
- hn.setUse(toNameUse(nameType));
- }
- if (hn.isEmpty()) {
- return null;
- }
- return hn;
- }
-
- /*
- */
- private static NameUse toNameUse(String nameType) {
- if (nameType == null) {
- return null;
- }
- switch (StringUtils.upperCase(nameType)) {
- case "A" : // Assigned
- return NameUse.USUAL;
- case "D" : // Customary Name
- return NameUse.USUAL;
- case "L" : // Official Registry Name
- return NameUse.OFFICIAL;
- case "M" : // Maiden Name
- return NameUse.MAIDEN;
- case "N" : // Nickname
- return NameUse.NICKNAME;
- case "NOUSE" : // No Longer To Be Used
- return NameUse.OLD;
- case "R" : // Registered Name
- return NameUse.OFFICIAL;
- case "TEMP" : // Temporary Name
- return NameUse.TEMP;
- case "B", // Birth name
- "BAD", // Bad Name
- "C", // Adopted Name
- "I", // Licensing Name
- "K", // Business name
- "MSK", // Masked
- "NAV", // Temporarily Unavailable
- "NB", // Newborn Name
- "P", // Name of Partner/Spouse
- "REL", // Religious
- "S", // Pseudonym
- "T", // Indigenous/Tribal
- "U" : // Unknown
- default :
- return null;
- }
- }
-
- private static boolean isAffix(String part) {
- return affixes.contains(part.toLowerCase());
- }
-
- private static boolean isPrefix(String part) {
- return prefixes.contains(part.toLowerCase().replace(".", ""));
- }
-
- private static boolean isSuffix(String part) {
- return suffixes.contains(part.toLowerCase().replace(".", ""));
- }
-
- private static boolean isDegree(String part) {
- return degrees.contains(part.toLowerCase().replace(".", ""));
- }
-
-}
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/converter/package-info.java b/src/main/java/gov/cdc/izgw/v2tofhir/converter/package-info.java
new file mode 100644
index 0000000..40d8a0a
--- /dev/null
+++ b/src/main/java/gov/cdc/izgw/v2tofhir/converter/package-info.java
@@ -0,0 +1,26 @@
+/**
+ * The converter package contains classes for parsing and converting HL7 V2 messages, segments and datatypes
+ * into FHIR Bundles, Resources and datatypes respectively.
+ *
+ * The key classes are MessageParser, DatatypeConverter, and Context, and from these three classes you
+ * can access everything necessary to convert HL7 V2 messages and components into FHIR objects.
+ *
+ * Converting a HAPI V2 Message into a FHIR Bundle is as simple as:
+ *
+ * Bundle b = new MessageParser().convert(message);
+ *
+ *
+ * Converting a collection of HAPI V2 Segments or Groups is as simple as:
+ *
+ * Bundle b = new MessageParser().convert(segments);
+ *
+ *
+ * Converting a single HAPI V2 segment or group is as simple as:
+ *
+ * Bundle b = new MessageParser().convert(Collections.singleton(segment));
+ *
+ *
+ * @see Github
+ */
+ package gov.cdc.izgw.v2tofhir.converter;
+
\ No newline at end of file
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/AbstractSegmentParser.java b/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/AbstractSegmentParser.java
deleted file mode 100644
index a04ec3f..0000000
--- a/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/AbstractSegmentParser.java
+++ /dev/null
@@ -1,93 +0,0 @@
-package gov.cdc.izgw.v2tofhir.converter.segment;
-
-import java.util.List;
-
-import org.hl7.fhir.r4.model.Bundle;
-import org.hl7.fhir.r4.model.Resource;
-
-import ca.uhn.hl7v2.HL7Exception;
-import ca.uhn.hl7v2.model.Segment;
-import ca.uhn.hl7v2.model.Type;
-import gov.cdc.izgw.v2tofhir.converter.Context;
-import gov.cdc.izgw.v2tofhir.converter.MessageParser;
-import lombok.Data;
-import lombok.extern.slf4j.Slf4j;
-
-@Data
-@Slf4j
-public abstract class AbstractSegmentParser implements SegmentParser {
- private final MessageParser messageParser;
- private final String segmentName;
-
- AbstractSegmentParser(MessageParser messageParser, String segmentName) {
- this.messageParser = messageParser;
- this.segmentName = segmentName;
- }
-
- @Override
- public String segment() {
- return segmentName;
- }
- public Context getContext() {
- return messageParser.getContext();
- }
-
- public Bundle getBundle() {
- return getContext().getBundle();
- }
-
- public R getFirstResource(Class clazz) {
- R r = messageParser.getFirstResource(clazz);
- if (r == null) {
- log.warn("No {} has been created", clazz.getSimpleName());
- }
- return r;
- }
- public R getLastResource(Class clazz) {
- return messageParser.getLastResource(clazz);
- }
- public R getResource(Class clazz, String id) {
- return messageParser.getResource(clazz, id);
- }
- public List getResources(Class clazz) {
- return messageParser.getResources(clazz);
- }
-
- public R createResource(Class clazz) {
- return messageParser.findResource(clazz, null);
- }
-
- public R findResource(Class clazz, String id) {
- return messageParser.findResource(clazz, id);
- }
-
- public static Type getField(Segment segment, int field) {
- if (segment == null) {
- return null;
- }
- try {
- Type[] types = segment.getField(field);
- if (types.length == 0) {
- return null;
- }
- return types[0].isEmpty() ? null : types[0];
- } catch (HL7Exception e) {
- return null;
- }
- }
-
- public static Type[] getFields(Segment segment, int field) {
- if (segment == null) {
- return new Type[0];
- }
- try {
- return segment.getField(field);
- } catch (HL7Exception e) {
- return new Type[0];
- }
- }
-
- public T getProperty(Class t) {
- return messageParser.getContext().getProperty(t);
- }
-}
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/ERRParser.java b/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/ERRParser.java
deleted file mode 100644
index 1781348..0000000
--- a/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/ERRParser.java
+++ /dev/null
@@ -1,141 +0,0 @@
-package gov.cdc.izgw.v2tofhir.converter.segment;
-
-import java.util.LinkedHashMap;
-
-import org.hl7.fhir.r4.model.CodeableConcept;
-import org.hl7.fhir.r4.model.Coding;
-import org.hl7.fhir.r4.model.MessageHeader;
-import org.hl7.fhir.r4.model.OperationOutcome;
-import org.hl7.fhir.r4.model.StringType;
-import org.hl7.fhir.r4.model.OperationOutcome.IssueSeverity;
-import org.hl7.fhir.r4.model.OperationOutcome.IssueType;
-import org.hl7.fhir.r4.model.OperationOutcome.OperationOutcomeIssueComponent;
-
-import ca.uhn.hl7v2.HL7Exception;
-import ca.uhn.hl7v2.model.Segment;
-import ca.uhn.hl7v2.model.Type;
-import gov.cdc.izgw.v2tofhir.converter.DatatypeConverter;
-import gov.cdc.izgw.v2tofhir.converter.Mapping;
-import gov.cdc.izgw.v2tofhir.converter.MessageParser;
-import gov.cdc.izgw.v2tofhir.converter.ParserUtils;
-import gov.cdc.izgw.v2tofhir.converter.PathUtils;
-import lombok.extern.slf4j.Slf4j;
-
-@Slf4j
-public class ERRParser extends AbstractSegmentParser {
- private static final LinkedHashMap errorCodeMap = new LinkedHashMap<>();
- private static final String[][] errorCodeMapping = {
- { "success", "0", "Message accepted", "Success. Optional, as the AA conveys success. Used for systems that must always return a status code." },
- { "strucure", "100", "Segment sequence error", "Error: The message segments were not in the proper order, or required segments are missing." },
- { "required", "101", "Required field missing", "Error: A required field is missing from a segment" },
- { "value", "102", "Data type error", "Error: The field contained data of the wrong data type, e.g. an NM field contained" },
- { "code-invalid", "103", "Table value not found", "Error: A field of data type ID or IS was compared against the corresponding table, and no match was found." },
- { "not-supported", "200", "Unsupported message type", "Rejection: The Message Type is not supported." },
- { "not-supported", "201", "Unsupported event code", "Rejection: The Event Code is not supported." },
- { "not-supported", "202", "Unsupported processing id", "Rejection: The Processing ID is not supported." },
- { "not-supported", "203", "Unsupported version id", "Rejection: The Version ID is not supported." },
- { "not-found", "204", "Unknown key identifier", "Rejection: The ID of the patient, order, etc., was not found. Used for transactions other than additions, e.g. transfer of a non-existent patient." },
- { "conflict", "205", "Duplicate key identifier", "Rejection: The ID of the patient, order, etc., already exists. Used in response to addition transactions (Admit, New Order, etc.)." },
- { "lock-error", "206", "Application record locked", "Rejection: The transaction could not be performed at the application storage level, e.g., database locked." },
- { "exception", "207", "Application internal error", "Rejection: A catchall for internal errors not explicitly covered by other codes." }
- };
- static {
- for (String[] mapping : errorCodeMapping) {
- errorCodeMap.put(mapping[1], mapping);
- }
- }
- public ERRParser(MessageParser messageParser) {
- super(messageParser, "ERR");
- }
- @Override
- public void parse(Segment err) throws HL7Exception {
- // Create a new OperationOutcome for each ERR resource
- OperationOutcome oo = getFirstResource(OperationOutcome.class);
- if (oo == null) {
- oo = createResource(OperationOutcome.class);
- MessageHeader mh = getFirstResource(MessageHeader.class);
- if (mh == null) {
- mh = createResource(MessageHeader.class);
- }
- mh.getResponse().setDetails(ParserUtils.toReference(oo));
- }
-
- OperationOutcomeIssueComponent issue = oo.addIssue();
- Type[] locations = getFields(err, 2); // Location: ERL (can repeat)
- if (locations.length != 0) {
- for (Type location: locations) {
- try {
- String encoded = location.encode();
- issue.addLocation(encoded);
- issue.addExpression(PathUtils.v2ToFHIRPath(encoded));
- } catch (HL7Exception ex) {
- warnException("parsing {}-2", "ERR", err, ex);
- // Quietly ignore this
- }
- }
- }
-
- CodeableConcept errorCode = DatatypeConverter.toCodeableConcept(getField(err, 3), "HL70357");
- if (errorCode != null) {
- issue.setDetails(errorCode);
- for (Coding c: errorCode.getCoding()) {
- // If from table 0357
- if ("http://terminology.hl7.org/CodeSystem/v2-0357".equals(c.getSystem())) { // Check the system.
- String[] map = errorCodeMap.get(c.getCode());
- if (map != null) {
- issue.setCode(IssueType.fromCode(map[0]));
- } else {
- issue.setCode(IssueType.PROCESSING);
- }
- }
- }
- }
-
- Coding severity = DatatypeConverter.toCoding(getField(err, 4), "HL70516"); // "HL70516"
- if (severity != null && severity.hasCode()) {
- switch (severity.getCode().toUpperCase()) {
-
- case "I": issue.setSeverity(IssueSeverity.INFORMATION); break;
- case "W": issue.setSeverity(IssueSeverity.WARNING); break;
- case "E": // Fall through
- default: issue.setSeverity(IssueSeverity.ERROR); break;
- }
- if (errorCode == null) {
- errorCode = new CodeableConcept();
- issue.setDetails(errorCode);
- }
- errorCode.addCoding(severity);
- }
-
- issue.setDetails(getDetails(err, issue.getDetails()));
- issue.setDiagnostics(getDiagnostics(err));
- }
- private CodeableConcept getDetails(Segment err, CodeableConcept details) {
- CodeableConcept appErrorCode = DatatypeConverter.toCodeableConcept(getField(err, 5)); // Application Error Code: CWE
- if (appErrorCode != null) {
- if (details == null || details.isEmpty()) {
- return appErrorCode;
- }
- appErrorCode.getCoding().forEach(details::addCoding);
- }
- return details;
- }
- private String getDiagnostics(Segment err) {
- StringType diagnostics = DatatypeConverter.toStringType(getField(err, 7)); // Diagnostics: TX
- StringType userMessage = DatatypeConverter.toStringType(getField(err, 8)); // User Message
- if (diagnostics != null) {
- if (userMessage != null) {
- return diagnostics + "\n" + userMessage;
- }
- return diagnostics.toString();
- }
- return userMessage == null || userMessage.isBooleanPrimitive() ? null : userMessage.toString();
- }
-
- private void warn(String msg, Object ...args) {
- log.warn(msg, args);
- }
- private void warnException(String msg, Object ...args) {
- log.warn(msg, args);
- }
-}
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/MSAParser.java b/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/MSAParser.java
deleted file mode 100644
index a55b092..0000000
--- a/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/MSAParser.java
+++ /dev/null
@@ -1,76 +0,0 @@
-package gov.cdc.izgw.v2tofhir.converter.segment;
-
-import org.hl7.fhir.r4.model.Coding;
-import org.hl7.fhir.r4.model.MessageHeader;
-import org.hl7.fhir.r4.model.OperationOutcome;
-import org.hl7.fhir.r4.model.MessageHeader.MessageHeaderResponseComponent;
-import org.hl7.fhir.r4.model.MessageHeader.ResponseType;
-import org.hl7.fhir.r4.model.OperationOutcome.IssueSeverity;
-import org.hl7.fhir.r4.model.OperationOutcome.IssueType;
-import org.hl7.fhir.r4.model.OperationOutcome.OperationOutcomeIssueComponent;
-
-import ca.uhn.hl7v2.HL7Exception;
-import ca.uhn.hl7v2.model.Segment;
-import gov.cdc.izgw.v2tofhir.converter.DatatypeConverter;
-import gov.cdc.izgw.v2tofhir.converter.MessageParser;
-import gov.cdc.izgw.v2tofhir.converter.ParserUtils;
-import lombok.extern.slf4j.Slf4j;
-
-@Slf4j
-public class MSAParser extends AbstractSegmentParser {
-
- public MSAParser(MessageParser messageParser) {
- super(messageParser, "MSA");
- }
-
- @Override
- public void parse(Segment msa) throws HL7Exception {
- MessageHeader mh = getFirstResource(MessageHeader.class);
- if (mh == null) {
- // Log but otherwise ignore this.
- log.error("MSH segment not parsed");
- mh = createResource(MessageHeader.class);
- }
- Coding acknowledgementCode = DatatypeConverter.toCoding(ParserUtils.getField(msa, 1), "0008");
- MessageHeaderResponseComponent response = mh.getResponse();
-
- OperationOutcomeIssueComponent issue = createIssue();
-
- if (acknowledgementCode == null) {
- response.setCode(ResponseType.OK);
- } else {
- issue.getDetails().addCoding(acknowledgementCode);
- switch (acknowledgementCode.getCode()) {
- case "AA", "CA":
- response.setCode(ResponseType.OK);
- issue.setCode(IssueType.INFORMATIONAL);
- issue.setSeverity(IssueSeverity.INFORMATION);
- break;
- case "AE", "CE":
- response.setCode(ResponseType.TRANSIENTERROR);
- issue.setCode(IssueType.TRANSIENT);
- issue.setSeverity(IssueSeverity.ERROR);
- break;
- case "AR", "CR":
- response.setCode(ResponseType.FATALERROR);
- issue.setCode(IssueType.PROCESSING);
- issue.setSeverity(IssueSeverity.FATAL);
- break;
- default:
- break;
- }
- }
- response.setIdentifierElement(DatatypeConverter.toIdType(ParserUtils.getField(msa, 2)));
- }
-
- private OperationOutcomeIssueComponent createIssue() {
- OperationOutcomeIssueComponent issue = null;
- OperationOutcome oo = getFirstResource(OperationOutcome.class);
- if (oo == null) {
- oo = createResource(OperationOutcome.class);
- }
- issue = oo.getIssueFirstRep();
- getMessageParser().getContext().setProperty(issue);
- return issue;
- }
-}
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/MSHParser.java b/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/MSHParser.java
deleted file mode 100644
index 8f1a79e..0000000
--- a/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/MSHParser.java
+++ /dev/null
@@ -1,113 +0,0 @@
-package gov.cdc.izgw.v2tofhir.converter.segment;
-
-import org.hl7.fhir.r4.model.CodeableConcept;
-import org.hl7.fhir.r4.model.Coding;
-import org.hl7.fhir.r4.model.DateTimeType;
-import org.hl7.fhir.r4.model.Identifier;
-import org.hl7.fhir.r4.model.MessageHeader;
-import org.hl7.fhir.r4.model.Organization;
-import org.hl7.fhir.r4.model.Provenance;
-import org.hl7.fhir.r4.model.Reference;
-import org.hl7.fhir.r4.model.MessageHeader.MessageDestinationComponent;
-import org.hl7.fhir.r4.model.MessageHeader.MessageSourceComponent;
-import ca.uhn.hl7v2.HL7Exception;
-import ca.uhn.hl7v2.model.Segment;
-import ca.uhn.hl7v2.model.Type;
-import gov.cdc.izgw.v2tofhir.converter.DatatypeConverter;
-import gov.cdc.izgw.v2tofhir.converter.MessageParser;
-import gov.cdc.izgw.v2tofhir.converter.ParserUtils;
-import lombok.extern.slf4j.Slf4j;
-
-@Slf4j
-public class MSHParser extends AbstractSegmentParser {
-
- private static final CodeableConcept AUTHOR_AGENT = new CodeableConcept().addCoding(new Coding("http://terminology.hl7.org/CodeSystem/provenance-participant-type", "author", "Author"));
-
- public MSHParser(MessageParser messageParser) {
- super(messageParser, "MSG");
- }
-
- @Override
- public void parse(Segment msh) throws HL7Exception {
- // Create a new MessageHeader for each ERR resource
- MessageHeader mh = createResource(MessageHeader.class);
- Provenance provenance = (Provenance) mh.getUserData(Provenance.class.getName());
- provenance.getActivity().addCoding(new Coding(null, "v2-FHIR transformation", "HL7 V2 to FHIR transformation"));
- Organization sourceOrg = getOrganizationFromMsh(msh, true);
- if (sourceOrg != null) {
- Reference ref = ParserUtils.toReference(sourceOrg);
- mh.setResponsible(ref);
- provenance.addAgent().addRole(AUTHOR_AGENT).setWho(ref);
- }
- Organization destOrg = getOrganizationFromMsh(msh, false);
- if (destOrg != null) {
- mh.getDestinationFirstRep().setReceiver(ParserUtils.toReference(destOrg));
- }
-
- MessageSourceComponent source = mh.getSource();
- source.setName(getSystem(DatatypeConverter.toCoding(ParserUtils.getField(msh, 3)))); // Sending Application
- source.setEndpoint(getSystem(DatatypeConverter.toCoding(ParserUtils.getField(msh, 4)))); // Sending Facility
-
- MessageDestinationComponent destination = mh.getDestinationFirstRep();
- destination.setName(getSystem(DatatypeConverter.toCoding(ParserUtils.getField(msh, 5)))); // Receiving Application
- destination.setEndpoint(getSystem(DatatypeConverter.toCoding(ParserUtils.getField(msh, 6)))); // Receiving Facility
- DateTimeType ts = DatatypeConverter.toDateTimeType(ParserUtils.getField(msh, 7));
- if (ts != null) {
- getBundle().setTimestamp(ts.getValue());
- }
- Type messageType = ParserUtils.getField(msh, 9);
- mh.setEvent(DatatypeConverter.toCodingFromTriggerEvent(messageType));
- /* TODO: It feels like this item may be desirable to have in the message header.
- Coding messageCode = DatatypeConverter.toCodingFromMessageCode(messageType);
- */
- Coding messageStructure = DatatypeConverter.toCodingFromMessageStructure(messageType);
- if (messageStructure != null) {
- // It's about as close as we get, and has the virtue of being a FHIR StructureDefinition
- mh.setDefinition("http://v2plus.hl7.org/2021Jan/message-structure/" + messageStructure.getCode());
- }
- Identifier messageId = DatatypeConverter.toIdentifier(ParserUtils.getField(msh, 10));
- getBundle().setIdentifier(messageId);
- // TODO: Deal with MSH-24 and MSH-25 when https://jira.hl7.org/browse/V2-25792 is resolved
- }
- private Organization getOrganizationFromMsh(Segment msh, boolean isSender) {
- Organization org;
- Type organization = ParserUtils.getField(msh, isSender ? 22 : 23);
- Type facility = ParserUtils.getField(msh, isSender ? 4 : 6);
- if (organization != null) {
- org = toOrganization(organization);
- if (org == null) {
- org = toOrganization(facility);
- } else {
- Identifier ident = DatatypeConverter.toIdentifier(facility);
- if (ident != null) {
- org.addIdentifier(ident);
- }
- }
- } else {
- org = toOrganization(facility);
- }
- Type application = ParserUtils.getField(msh, isSender ? 3 : 5);
- if (org != null) {
- Identifier ident = DatatypeConverter.toIdentifier(application);
- if (ident != null) {
- org.addEndpoint().setIdentifier(ident);
- }
- }
- return org;
- }
- private String getSystem(Coding coding) {
- return coding == null ? null : coding.getSystem();
- }
-
- public Organization toOrganization(Type t) {
- if ("XON".equals(t.getName())) {
- Organization org = createResource(Organization.class);
- org.setName(ParserUtils.toString(t));
- Identifier ident = DatatypeConverter.toIdentifier(t);
- if (ident != null) {
- org.addIdentifier(ident);
- }
- }
- return null;
- }
-}
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/QAKParser.java b/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/QAKParser.java
deleted file mode 100644
index 5cc93b2..0000000
--- a/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/QAKParser.java
+++ /dev/null
@@ -1,58 +0,0 @@
-package gov.cdc.izgw.v2tofhir.converter.segment;
-
-import org.hl7.fhir.r4.model.CodeableConcept;
-import org.hl7.fhir.r4.model.Coding;
-import org.hl7.fhir.r4.model.OperationOutcome.IssueSeverity;
-import org.hl7.fhir.r4.model.OperationOutcome.IssueType;
-import org.hl7.fhir.r4.model.OperationOutcome.OperationOutcomeIssueComponent;
-
-import ca.uhn.hl7v2.HL7Exception;
-import ca.uhn.hl7v2.model.Segment;
-import gov.cdc.izgw.v2tofhir.converter.DatatypeConverter;
-import gov.cdc.izgw.v2tofhir.converter.MessageParser;
-import gov.cdc.izgw.v2tofhir.converter.ParserUtils;
-import lombok.extern.slf4j.Slf4j;
-
-@Slf4j
-public class QAKParser extends AbstractSegmentParser {
-
- public QAKParser(MessageParser messageParser) {
- super(messageParser, "QAK");
- }
-
- @Override
- public void parse(Segment qak) throws HL7Exception {
- Coding responseStatus = DatatypeConverter.toCoding(ParserUtils.getField(qak, 2), "0208");
- if (responseStatus != null) {
- OperationOutcomeIssueComponent issue = getProperty(OperationOutcomeIssueComponent.class);
- if (issue != null) {
- if (responseStatus.hasCode()) {
- switch (responseStatus.getCode().toUpperCase()) {
- case "AE":
- issue.setCode(IssueType.INVALID);
- issue.setSeverity(IssueSeverity.ERROR);
- break;
- case "AR":
- issue.setCode(IssueType.PROCESSING);
- issue.setSeverity(IssueSeverity.FATAL);
- break;
- case "NF", "OK":
- default:
- issue.setCode(IssueType.INFORMATIONAL);
- issue.setSeverity(IssueSeverity.INFORMATION);
- break;
- }
- issue.setDetails(new CodeableConcept().addCoding(responseStatus));
- } else {
- issue.setCode(IssueType.UNKNOWN);
- issue.setSeverity(IssueSeverity.INFORMATION);
- }
- } else {
- warn("Missing {} segment in message while processing {}", "MSA", qak);
- }
- }
- }
- private static void warn(String msg, Object ...args) {
- log.warn(msg, args);
- }
-}
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/SegmentParser.java b/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/SegmentParser.java
deleted file mode 100644
index 829b85d..0000000
--- a/src/main/java/gov/cdc/izgw/v2tofhir/converter/segment/SegmentParser.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package gov.cdc.izgw.v2tofhir.converter.segment;
-
-import ca.uhn.hl7v2.HL7Exception;
-import ca.uhn.hl7v2.model.Segment;
-
-public interface SegmentParser {
- /** The name of the segment this parser works on */
- String segment();
- /**
- * Parse the segment into FHIR Resources in the bundle being
- * prepared by a MessageParser
- * @throws HL7Exception If an error occurs reading the HL7 message content
- **/
- void parse(Segment seg) throws HL7Exception;
-}
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/converter/datatype/AddressParser.java b/src/main/java/gov/cdc/izgw/v2tofhir/datatype/AddressParser.java
similarity index 63%
rename from src/main/java/gov/cdc/izgw/v2tofhir/converter/datatype/AddressParser.java
rename to src/main/java/gov/cdc/izgw/v2tofhir/datatype/AddressParser.java
index 6714dc5..4098199 100644
--- a/src/main/java/gov/cdc/izgw/v2tofhir/converter/datatype/AddressParser.java
+++ b/src/main/java/gov/cdc/izgw/v2tofhir/datatype/AddressParser.java
@@ -1,22 +1,32 @@
-package gov.cdc.izgw.v2tofhir.converter.datatype;
+package gov.cdc.izgw.v2tofhir.datatype;
-import java.util.Arrays;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.hl7.fhir.r4.model.Address;
import org.hl7.fhir.r4.model.Address.AddressUse;
+import org.hl7.fhir.r4.model.StringType;
import ca.uhn.hl7v2.model.Composite;
import ca.uhn.hl7v2.model.Primitive;
import ca.uhn.hl7v2.model.Type;
-import ca.uhn.hl7v2.model.Varies;
-import gov.cdc.izgw.v2tofhir.converter.ParserUtils;
+import gov.cdc.izgw.v2tofhir.converter.DatatypeConverter;
+import gov.cdc.izgw.v2tofhir.utils.ParserUtils;
import lombok.extern.slf4j.Slf4j;
+/**
+ * AddressParser is a parser for addresses.
+ *
+ * @author Audacious Inquiry
+ */
@Slf4j
public class AddressParser implements DatatypeParser {
-
+ static {
+ log.debug("{} loaded", AddressParser.class.getName());
+ }
private static final Pattern caPostalCode1 = Pattern
.compile("^[a-zA-Z]\\d[a-zA-Z]$");
@@ -32,9 +42,8 @@ public class AddressParser implements DatatypeParser {
private static final Pattern postalCodePattern = Pattern.compile(
"^\\d{5}$|^\\d{5}-\\d{4}$|^[a-zA-Z]\\d[a-zA-Z]-\\d[a-zA-Z]\\d$");
-
- private static final Pattern statePattern = ParserUtils.toPattern("AL",
- "Alabama", "AK", "Alaska", "AZ", "Arizona", "AR", "Arkansas", "AS",
+ private static final String[] states = {
+ "AL", "Alabama", "AK", "Alaska", "AZ", "Arizona", "AR", "Arkansas", "AS",
"American Samoa", "CA", "California", "CO", "Colorado", "CT",
"Connecticut", "DE", "Delaware", "DC", "District of Columbia", "FL",
"Florida", "GA", "Georgia", "GU", "Guam", "HI", "Hawaii", "ID",
@@ -65,7 +74,18 @@ public class AddressParser implements DatatypeParser {
"NUEVO LEON", "OA", "OAXACA", "PU", "PUEBLA", "QE", "QUERETARO",
"QI", "QUINTANA ROO", "SI", "SINALOA", "SL", "SAN LUIS POTOSI",
"SO", "SONORA", "TA", "TAMAULIPAS", "TB", "TABASCO", "TL",
- "TLAXCALA", "VC", "VERACRUZ", "YU", "YUCATAN", "ZA", "ZACATECA");
+ "TLAXCALA", "VC", "VERACRUZ", "YU", "YUCATAN", "ZA", "ZACATECA"
+ };
+ private static final Pattern statePattern = ParserUtils.toPattern(states);
+ private static final Map STATE_MAP = new LinkedHashMap<>();
+ static {
+ for (int i = 0; i < states.length; i += 2) {
+ String[] stateAndAbbreviation = { states[i], states[i + 1] };
+ STATE_MAP.put(stateAndAbbreviation[0], stateAndAbbreviation);
+ STATE_MAP.put(stateAndAbbreviation[1].toUpperCase(), stateAndAbbreviation);
+ }
+ }
+
// See https://pe.usps.com/text/pub28/28apc_002.htm
private static final Pattern streetPattern = ParserUtils.toPattern("ALLEE",
"ALLEY", "ALLY", "ALY", "ANEX", "ANNEX", "ANNX", "ANX", "ARC",
@@ -167,123 +187,281 @@ public class AddressParser implements DatatypeParser {
"SW", "SUROESTE", "SOUTHWEST", "E", "ESTE", "EAST", "W", "OESTE",
"WEST");
+ /**
+ * Construct a new AddressParser
+ */
+ public AddressParser() {
+ // Construct the default address parser
+ }
+
+ /**
+ * Get state abbreviation for state name or abbreviation
+ * @param state state name or abbreviation
+ * @return The state abbreviation
+ */
+ public static String getStateAbbreviation(String state) {
+ String[] array = STATE_MAP.get(StringUtils.upperCase(state));
+ return array == null ? null : array[0];
+ }
+
+ /**
+ * Get state name for state name or abbreviation
+ * @param state state name or abbreviation
+ * @return The state name
+ */
+ public static String getStateName(String state) {
+ String[] array = STATE_MAP.get(StringUtils.upperCase(state));
+ return array == null ? null : array[1];
+ }
+
@Override
public Class type() {
return Address.class;
}
+
@Override
public Address convert(Type type) {
Address addr = null;
- if (type instanceof Varies v) {
- type = v.getData();
- }
+ type = DatatypeConverter.adjustIfVaries(type);
+
if (type instanceof Primitive pt) {
addr = fromString(pt.getValue());
- } else if (type instanceof Composite comp && Arrays.asList("AD", "XAD")
- .contains(type.getName())) {
- addr = parse(comp.getComponents());
- } else if (type instanceof Composite comp && "SAD".equals(type.getName())) {
- addr = new Address().addLine(ParserUtils.toString(comp, 0));
- }
+ } else if (type instanceof Composite comp) {
+ switch (type.getName()) {
+ case "AD", "XAD":
+ addr = parse(comp.getComponents(), 0);
+ break;
+ case "SAD":
+ addr = new Address().addLine(ParserUtils.toString(comp, 0));
+ break;
+ case "LA1":
+ Type[] types = comp.getComponents();
+ if (types.length > 8) {
+ // Recursive call to parse LA1-8 which is of type AD
+ addr = convert(types[8]);
+ }
+ break;
+ case "LA2":
+ addr = parse(comp.getComponents(), 8);
+ break;
+ default:
+ break;
+ }
+ }
+
+ removeEmptyLines(addr);
if (addr == null || addr.isEmpty()) {
return null;
}
return addr;
}
+
+ private void removeEmptyLines(Address addr) {
+ if (addr != null && addr.hasLine()) {
+ Iterator it = addr.getLine().iterator();
+ while (it.hasNext()) {
+ StringType line = it.next();
+ if (line == null || line.isEmpty()) {
+ it.remove();
+ }
+ }
+ }
+ }
+
+ @Override
+ public Type convert(Address addr) {
+ return null;
+ }
@Override
public Address fromString(String value) {
if (StringUtils.isBlank(value)) {
return null;
}
- value = StringUtils.normalizeSpace(value);
String[] parts = value.split("[\\n\\r]");
+ String[] originalParts = parts;
if (parts.length == 1) {
parts = value.split(",");
}
Address addr = new Address();
addr.setText(value);
for (int i = 0; i < parts.length; i++) {
- String part = parts[i].trim();
- if (part.isEmpty()) {
+ String part = StringUtils.normalizeSpace(parts[i]);
+ if (part.isEmpty()) {
continue;
}
if (!addr.hasLine()) {
- // First line is usually an address line
- addr.addLine(part);
- } else if (streetPattern.matcher(part).matches()
- || directionalPattern.matcher(part).matches()
- || unitPattern.matcher(part).matches()) {
+ getLineOrCsz(originalParts, addr, part);
+ continue;
+ }
+
+ if (isAddressLine(part)) {
addr.addLine(part);
- } else if (!addr.hasCountry()
- && countryPattern.matcher(part).matches()) {
+ continue;
+ }
+
+ if (isCountry(addr, part)) {
addr.setCountry(part);
- } else if (!addr.hasPostalCode()) {
+ continue;
+ }
+
+ if (!addr.hasPostalCode()) {
getCityStatePostalCode(addr, part);
}
}
- return null;
+ return addr.isEmpty() ? null : addr;
+ }
+ private void getLineOrCsz(String[] originalParts, Address addr, String part) {
+ // First line is usually an address line, but
+ // sometimes it might just be a city, state and postal code
+ // in cases of partial representations.
+ if (isCszOnly(originalParts)) {
+ getCityStatePostalCode(addr, part);
+ } else {
+ addr.addLine(part);
+ }
}
+ /**
+ * Returns true if part is a possible country name
+ * @param addr The addr that may need a country
+ * @param part The part to examine
+ * @return true if addr needs country and part is a country name
+ */
+ private boolean isCountry(Address addr, String part) {
+ return !addr.hasCountry()
+ && countryPattern.matcher(part).matches();
+ }
+ /**
+ * Returns true if an address part matches patterns indicating it is an address line.
+ *
+ * NOTE: Not all address lines will be matched, only those containing certain patterns
+ * that match (e.g., those with a street name or abbreviation, a directional indicator, or a unit number)
+ * @param part The string to match
+ * @return true if it appears to be an address line, false if it doesn't match any known patterns.
+ */
+ private boolean isAddressLine(String part) {
+ return streetPattern.matcher(part).matches()
+ || directionalPattern.matcher(part).matches()
+ || unitPattern.matcher(part).matches();
+ }
+
+ /**
+ * Check for odd cases like:
+ * Just CSZ: Chicago, IL, 65932
+ * Just CS and Country: Myfaircity, GA\nUSA
+ * Just State: Indiana
+ * @return true if it's an odd case.
+ */
+ private boolean isCszOnly(String[] parts) {
+ if (parts.length > 2) {
+ return false;
+ }
+ if (parts.length == 2 &&
+ !countryPattern.matcher(StringUtils.trim(parts[1])).matches()
+ ) {
+ return false;
+ }
+ return isCszOnly(StringUtils.trim(parts[0]));
+ }
+
+ private boolean isCszOnly(String line) {
+ boolean hasCa1Part = false;
+ boolean hasPostalCode = false;
+ boolean hasState = false;
+ boolean hasAnythingAfterState = false;
+ for (String part : line.split("[ ,]+")) {
+ if (postalCodePattern.matcher(part).matches()) {
+ hasPostalCode = true;
+ } else if (caPostalCode1.matcher(part).matches()) {
+ hasCa1Part = true;
+ } else if (caPostalCode2.matcher(part).matches()) {
+ hasPostalCode = hasCa1Part;
+ } else if (statePattern.matcher(part).matches()) {
+ hasState = true;
+ } else if (hasState) {
+ // Pennsylvania Ave would result in a false match if we didn't check
+ // for anything else on the line other that postalCode and state
+ hasAnythingAfterState = true;
+ }
+ }
+ return (hasState || hasPostalCode) && !hasAnythingAfterState;
+ }
+
private static void getCityStatePostalCode(Address addr, String part) {
// could be an address line, country, or some combination of
// city, state, and postal code
String[] lineParts = part.split("[, ]+");
- int position = getPostalCode(addr, lineParts);
- if (addr.hasPostalCode()) {
- // Remove the postal code from the line wherever it is.
- lineParts = ParserUtils.removeArrayElement(lineParts, position);
- }
- position = getState(addr, lineParts);
- if (addr.hasState()) {
- lineParts = ParserUtils.removeArrayElement(lineParts, position);
- }
- // If there was a postal code or state, city is anything lef.
- if (addr.hasPostalCode() || addr.hasState()) {
- addr.setCity(StringUtils.join(" ", lineParts));
+ lineParts = getPostalCode(addr, lineParts);
+ lineParts = getState(addr, lineParts);
+ lineParts = getCountry(addr, lineParts);
+ // City is anything left
+ if (lineParts.length != 0) {
+ addr.setCity(StringUtils.joinWith(" ", (Object[])lineParts));
}
}
- private static int getState(Address addr, String[] lineParts) {
- int position;
- position = 0;
+ private static String[] getCountry(Address addr, String[] lineParts) {
+ int position = 0;
for (String linePart : lineParts) {
+ if (countryPattern.matcher(linePart).matches()) {
+ addr.setCountry(linePart);
+ return ParserUtils.removeArrayElement(lineParts, position);
+ }
++position;
+ }
+ return lineParts;
+ }
+ private static String[] getState(Address addr, String[] lineParts) {
+ int position = 0;
+ for (String linePart : lineParts) {
if (statePattern.matcher(linePart).matches()) {
addr.setState(linePart);
- break;
+ return ParserUtils.removeArrayElement(lineParts, position);
}
+ ++position;
}
- return position;
+ return lineParts;
}
- private static int getPostalCode(Address addr, String[] lineParts) {
+ private static String[] getPostalCode(Address addr, String[] lineParts) {
String postalPart1 = null;
int position = 0;
for (String linePart : lineParts) {
- ++position;
if (postalCodePattern.matcher(linePart).matches()) {
addr.setPostalCode(linePart);
- break;
- } else if (postalPart1 == null
- && caPostalCode1.matcher(linePart).matches()) {
+ return ParserUtils.removeArrayElement(lineParts, position);
+ } else if (postalPart1 == null && caPostalCode1.matcher(linePart).matches()) {
postalPart1 = linePart;
- } else if (postalPart1 != null
- && caPostalCode2.matcher(linePart).matches()) {
+ } else if (postalPart1 != null && caPostalCode2.matcher(linePart).matches()) {
addr.setPostalCode(postalPart1 + " " + linePart);
- break;
+ // Remove the part we just matched
+ lineParts = ParserUtils.removeArrayElement(lineParts, position);
+ // And it's predecessor.
+ return ParserUtils.removeArrayElement(lineParts, position - 1);
} else {
postalPart1 = null;
}
+ ++position;
}
- return position;
+ return lineParts;
}
- public static Address parse(Type[] types) {
- int offset = 0;
+ /**
+ * Parse a composite made up of types into an address.
+ * @param types The components of the address
+ * @param offset The offset to the address line
+ * @return A new FHIR Address populated from the values in types.
+ */
+ public static Address parse(Type[] types, int offset) {
Address addr = new Address();
for (int i = 0; i < 14; i++) {
+
+ if (types.length + offset <= i) {
+ break;
+ }
+ Type t = DatatypeConverter.adjustIfVaries(types, i + offset);
if (i == 0) {
- addr.addLine(ParserUtils.toString(types[i + offset]));
- } else if (types[i + offset] instanceof Primitive part) {
+ addr.addLine(ParserUtils.toString(t));
+ } else if (t instanceof Primitive part) {
switch (i) {
case 1 :
addr.addLine(part.getValue());
@@ -317,6 +495,9 @@ public static Address parse(Type[] types) {
return addr;
}
private static AddressUse getUse(String value) {
+ if (StringUtils.isBlank(value)) {
+ return null;
+ }
switch (value.trim().toUpperCase()) {
case "B", // Firm/Business
"O" : // Office/Business
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/datatype/ContactPointParser.java b/src/main/java/gov/cdc/izgw/v2tofhir/datatype/ContactPointParser.java
new file mode 100644
index 0000000..f0e8ec9
--- /dev/null
+++ b/src/main/java/gov/cdc/izgw/v2tofhir/datatype/ContactPointParser.java
@@ -0,0 +1,393 @@
+package gov.cdc.izgw.v2tofhir.datatype;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.validator.routines.EmailValidator;
+import org.hl7.fhir.r4.model.ContactPoint;
+import org.hl7.fhir.r4.model.ContactPoint.ContactPointSystem;
+import org.hl7.fhir.r4.model.ContactPoint.ContactPointUse;
+import org.hl7.fhir.r4.model.DateTimeType;
+import org.hl7.fhir.r4.model.IntegerType;
+import org.hl7.fhir.r4.model.Period;
+import org.hl7.fhir.r4.model.StringType;
+
+import ca.uhn.hl7v2.model.Composite;
+import ca.uhn.hl7v2.model.Primitive;
+import ca.uhn.hl7v2.model.Type;
+import gov.cdc.izgw.v2tofhir.converter.DatatypeConverter;
+import gov.cdc.izgw.v2tofhir.utils.ParserUtils;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Parser that supports parsing a String, or HL7 Version into a FHIR ContactPoint.
+ *
+ * @author Audacious Inquiry
+ */
+@Slf4j
+public class ContactPointParser implements DatatypeParser {
+ static {
+ log.debug("{} loaded", ContactPointParser.class.getName());
+ }
+
+ static final String AREA_CODE = "\\(\\s*\\d{3}\s*\\)";
+ static final String COUNTRY_CODE = "\\+\\s*\\d{1,3}";
+ static final String PHONE_CHAR = "\\-+0123456789(). []{},#";
+ static final String PHONE_NUMBER = "(-|\\.|\\d+)+";
+ // [NN] [(999)]999-9999[X99999][B99999][C any text]
+ static final Pattern TN_PATTERN = Pattern.compile(
+ "((\\d{2,3})?(\\(\\d{3}\\))?\\d{3}-?\\d{4}(X\\d{1,5})?(B\\d1,5)?)(C.*)?$"
+ );
+ static final String CONTACTPOINT_COMMENT = "http://hl7.org/fhir/StructureDefinition/contactpoint-comment";
+
+ private static final EmailValidator EMAIL_VALIDATOR = EmailValidator.getInstance();
+ static StringBuilder appendIfNotBlank(StringBuilder b, String prefix, String string, String suffix) {
+ if (StringUtils.isBlank(string)) {
+ return b;
+ }
+ return b.append(StringUtils.defaultString(prefix))
+ .append(StringUtils.defaultString(string))
+ .append(StringUtils.defaultString(suffix));
+ }
+
+ /**
+ * Construct a ContactPointParser
+ */
+ public ContactPointParser() {
+ // Construct the default ContactPointParser
+ }
+
+ /**
+ * Convert an HL7 V2 XTN into a list of contacts.
+ * @param xtn The HL7 V2 xtn (or similarly shaped composite).
+ * @return A list of ContactPoint objects from the XTN.
+ */
+ public List convert(Composite xtn) {
+ Type[] types = xtn.getComponents();
+ List cps = new ArrayList<>();
+ if (types.length > 0) {
+ cps.add(convert(types[0])); // Convert XTN-1 to a phone number.
+ }
+ cps.add(fromEmail(ParserUtils.toString(types, 3)));
+ String value = fromXTNparts(types);
+ if (StringUtils.isNotBlank(value)) {
+ cps.add(new ContactPoint().setValue(value).setSystem(ContactPointSystem.PHONE));
+ }
+ String unformatted = ParserUtils.toString(types, 11);
+ if (StringUtils.isNotBlank(unformatted)) {
+ cps.add(new ContactPoint().setValue(unformatted).setSystem(ContactPointSystem.PHONE));
+ }
+ String comment = ParserUtils.toString(types, 8);
+ String use = StringUtils.defaultIfBlank(ParserUtils.toString(types, 1), "");
+ String type = StringUtils.defaultIfBlank(ParserUtils.toString(types, 2), "");
+ DateTimeType start = types.length > 12 ? DatatypeConverter.toDateTimeType(types[12]) : null;
+ DateTimeType end = types.length > 13 ? DatatypeConverter.toDateTimeType(types[13]) : null;
+ IntegerType order = types.length > 17 ? DatatypeConverter.toIntegerType(types[17]) : null;
+
+ Period period = null;
+ if (start != null || end != null) {
+ period = new Period().setStartElement(start).setEndElement(end);
+ }
+
+ cleanupContactPoints(cps, comment, use, type, order, period);
+ return cps;
+ }
+
+ @Override
+ public Type convert(ContactPoint type) {
+ // TODO Auto-generated method stub
+ return null;
+ }
+ @Override
+ public ContactPoint convert(Type type) {
+
+ if ((type = DatatypeConverter.adjustIfVaries(type)) == null) {
+ return null;
+ }
+
+ if (type instanceof Primitive) {
+ return fromString(ParserUtils.toString(type));
+ }
+
+ if ("XTN".equals(type.getName())) {
+ List cps = convert((Composite)type);
+ return cps.isEmpty() ? null : cps.get(0);
+ }
+
+ return null;
+ }
+
+ @Override
+ public ContactPoint fromString(String value) {
+ ContactPoint cp = fromPhone(value);
+ if (cp != null && !cp.isEmpty()) {
+ return cp;
+ }
+ cp = fromEmail(value);
+ if (cp != null && !cp.isEmpty()) {
+ return cp;
+ }
+ cp = fromUrl(value);
+ if (cp != null && !cp.isEmpty()) {
+ return cp;
+ }
+ return null;
+ }
+
+ /**
+ * Create a ContactPoint from a string representing an e-mail address.
+ *
+ * This method recognizes mailto: urls, and RFC-2822 email addresses
+ * @see #fromEmail(String)
+ *
+ * @param value The email address.
+ * @return A ContactPoint object representing this e-mail address.
+ */
+ public ContactPoint fromEmail(String value) {
+ if (StringUtils.isBlank(value)) {
+ return null;
+ }
+ value = StringUtils.strip(value).split("[;,]")[0];
+ String name = null;
+ String email = null;
+ if (value.contains("<")) {
+ name = StringUtils.substringBefore(value, "<");
+ email = StringUtils.substringBetween(value, "<", ">");
+ } else {
+ email = value;
+ }
+ if (StringUtils.startsWith(email, "mailto:")) {
+ email = email.substring(7);
+ }
+ /*
+ * Simplfied RFC-2822 grammar
+ * mailbox = name-addr / addr-spec
+ * name-addr = [display-name] angle-addr
+ * angle-addr = [CFWS] "<" addr-spec ">" [CFWS]
+ * display-name = word | quoted-string
+ * addr-spec = local-part "@" domain
+ * local-part = dot-atom / quoted-string / obs-local-part
+ * domain = dot-atom / domain-literal / obs-domain
+ * domain-literal = [CFWS] "[" *([FWS] dcontent) [FWS] "]" [CFWS]
+ * dcontent = dtext / quoted-pair
+ * dtext = NO-WS-CTL / ; Non white space controls
+ * %d33-90 / ; The rest of the US-ASCII
+ * %d94-126 ; characters not including "[", "]", or "\"
+ */
+ if (EMAIL_VALIDATOR.isValid(email)) {
+ ContactPoint cp = new ContactPoint();
+ cp.setSystem(ContactPointSystem.EMAIL);
+ cp.setValue(email);
+ if (StringUtils.isNotBlank(name)) {
+ cp.addExtension()
+ .setUrl(CONTACTPOINT_COMMENT)
+ .setValue(new StringType(name));
+ }
+ return cp;
+ }
+ return null;
+ }
+
+ /**
+ * Create a list of ContactPoints
+ * @param values A string providing the list of email addresses, separated by semi-colons, commas, or newlines.
+ * @return The list of contact points.
+ */
+ public List fromEmails(String values) {
+ if (StringUtils.isBlank(values)) {
+ return Collections.emptyList();
+ }
+ List cps = new ArrayList<>();
+ for (String value: StringUtils.strip(values).split("[;,\n\r]+")) {
+ ContactPoint cp = fromEmail(value);
+ if (cp != null && !cp.isEmpty()) {
+ cps.add(cp);
+ }
+ }
+ return cps;
+ }
+
+ /**
+ * Create a ContactPoint from a string representing a phone number.
+ *
+ * This method recognizes tel: urls and other strings containing phone dialing
+ * characters and punctuation.
+ *
+ * @param value The phone number.
+ * @return A ContactPoint object representing the phone number.
+ */
+ public ContactPoint fromPhone(String value) {
+ if (StringUtils.isBlank(value)) {
+ return null;
+ }
+ ContactPoint cp = null;
+ value = StringUtils.strip(value);
+ if (value.startsWith("tel:")) {
+ return new ContactPoint().setValue(value.substring(4)).setSystem(ContactPointSystem.PHONE);
+ }
+ if (value.startsWith("fax:")) {
+ return new ContactPoint().setValue(value.substring(4)).setSystem(ContactPointSystem.FAX);
+ }
+ if (value.startsWith("sms:")) {
+ return new ContactPoint().setValue(value.substring(4)).setSystem(ContactPointSystem.SMS);
+ }
+
+ Matcher m = TN_PATTERN.matcher(value);
+ if (m.matches()) {
+ cp = new ContactPoint();
+ // Could be fax or pager or SMS or other but we won't know without context.
+ cp.setSystem(ContactPointSystem.PHONE);
+ cp.setValue(m.group(1));
+ String comment = StringUtils.substringAfter(value, "C");
+ if (StringUtils.isNotBlank(comment)) {
+ cp.addExtension()
+ .setUrl(CONTACTPOINT_COMMENT)
+ .setValue(new StringType(comment));
+ }
+ return cp;
+ }
+ if (StringUtils.containsOnly(value, PHONE_CHAR) ||
+ value.matches(COUNTRY_CODE) || value.matches(AREA_CODE) || value.matches(PHONE_NUMBER)
+ ) {
+ cp = new ContactPoint();
+ cp.setSystem(ContactPointSystem.PHONE);
+ cp.setValue(value.replace(" ", ""));
+ return cp;
+ }
+ return null;
+ }
+
+ /**
+ * Create a ContactPoint from a URL
+ * @param url The URL to convert to a contact point
+ * @return A ContactPoint object representing the URL.
+ */
+ public ContactPoint fromUrl(String url) {
+ url = StringUtils.strip(url);
+ if (StringUtils.isEmpty(url)) {
+ return null;
+ }
+ String scheme = StringUtils.substringBefore(url, ":");
+ ContactPointSystem system = ContactPointSystem.URL;
+ if (StringUtils.isNotEmpty(scheme)) {
+ if ("mailto".equalsIgnoreCase(scheme))
+ system = ContactPointSystem.EMAIL;
+ else if ("tel".equalsIgnoreCase(scheme))
+ system = ContactPointSystem.PHONE;
+ else if ("fax".equalsIgnoreCase(scheme))
+ system = ContactPointSystem.FAX;
+ else if ("sms".equalsIgnoreCase(scheme))
+ system = ContactPointSystem.SMS;
+ return new ContactPoint()
+ .setValue(url)
+ .setSystem(system);
+ }
+ if (url.matches("^([\\-a-zA-Z0-9]+|\\d{1,3})([.\\-a-zA-Z0-9]+|\\d{1,3})+(/.*)?$")) {
+ if (url.startsWith("www") || url.contains("/")) {
+ url = "http://" + url;
+ system = ContactPointSystem.URL;
+ } else {
+ system = ContactPointSystem.OTHER;
+ }
+ return new ContactPoint()
+ .setValue(url)
+ .setSystem(system);
+ }
+ return null;
+ }
+
+ @Override
+ public Class extends ContactPoint> type() {
+ return ContactPoint.class;
+ }
+
+ private void cleanupContactPoints(List cps, String comment, String use, String type,
+ IntegerType order, Period period) {
+ // Cleanup list after parsing.
+ Iterator it = cps.iterator();
+ while (it.hasNext()) {
+ ContactPoint cp = it.next();
+ if (cp == null || cp.isEmpty() || StringUtils.isBlank(cp.getValue())) {
+ it.remove();
+ continue;
+ }
+ if (StringUtils.isNotBlank(comment)) {
+ cp.addExtension()
+ .setUrl(CONTACTPOINT_COMMENT)
+ .setValue(new StringType(comment));
+ }
+ mapUseCode(use, cp);
+ mapTypeCode(type, cp);
+ if (period != null && !period.isEmpty()) {
+ cp.setPeriod(period);
+ }
+ if (order != null && !order.isEmpty()) {
+ cp.setRank(order.getValue());
+ }
+ }
+ }
+
+ /**
+ * Create a phone number as a String from the components of XTN datatype.
+ * @param types The components of the XTN datatype
+ * @return The phone number as a string.
+ */
+ public static String fromXTNparts(Type[] types) {
+ String country = ParserUtils.toString(types, 4);
+ String area = ParserUtils.toString(types, 5);
+ String local = ParserUtils.toString(types, 6);
+ String extension = ParserUtils.toString(types, 7);
+ String extPrefix = ParserUtils.toString(types, 9);
+
+ if (!StringUtils.isAllBlank(country, area, local, extension)) {
+ StringBuilder b = new StringBuilder();
+ appendIfNotBlank(b, "+", country, " ");
+ appendIfNotBlank(b, "(", area, ") ");
+ appendIfNotBlank(b, null, local, null);
+ appendIfNotBlank(b, StringUtils.defaultIfBlank(extPrefix, "#"), extension, null);
+ return b.toString();
+ }
+ return null;
+ }
+
+ private void mapTypeCode(String type, ContactPoint cp) {
+ switch (type) {
+ case "BP": cp.setSystem(ContactPointSystem.PAGER); break;
+ case "CP":
+ cp.setSystem(ContactPointSystem.PHONE);
+ cp.setUse(ContactPointUse.MOBILE);
+ break;
+ case "FX": cp.setSystem(ContactPointSystem.FAX); break;
+ case "Internet": cp.setSystem(ContactPointSystem.URL); break;
+ case "MD": cp.setSystem(ContactPointSystem.OTHER); break;
+ case "PH", "SAT":
+ cp.setSystem(ContactPointSystem.PHONE); break;
+ case "TDD", "TTY": cp.setSystem(ContactPointSystem.OTHER); break;
+ case "X.400": cp.setSystem(ContactPointSystem.EMAIL); break;
+ default: break;
+ }
+ }
+
+ private void mapUseCode(String use, ContactPoint cp) {
+ switch (use) {
+ case "PRS": // Personal Number
+ cp.setUse(ContactPointUse.MOBILE); break;
+ case "ORN", // Other Residence Number
+ "PRN", // Primary Residence Number
+ "VHN": // Vacation Home Number
+ cp.setUse(ContactPointUse.HOME); break;
+ case "WPN": // Work Number
+ cp.setUse(ContactPointUse.WORK); break;
+ case "NET", // Network (email) Address
+ "ASN", // Answering Service Number
+ "BPN", // Beeper Number
+ "EMR": // Emergency Number
+ default: break;
+ }
+ }
+}
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/converter/datatype/DatatypeParser.java b/src/main/java/gov/cdc/izgw/v2tofhir/datatype/DatatypeParser.java
similarity index 65%
rename from src/main/java/gov/cdc/izgw/v2tofhir/converter/datatype/DatatypeParser.java
rename to src/main/java/gov/cdc/izgw/v2tofhir/datatype/DatatypeParser.java
index 0d12f33..c6c4ee3 100644
--- a/src/main/java/gov/cdc/izgw/v2tofhir/converter/datatype/DatatypeParser.java
+++ b/src/main/java/gov/cdc/izgw/v2tofhir/datatype/DatatypeParser.java
@@ -1,7 +1,13 @@
-package gov.cdc.izgw.v2tofhir.converter.datatype;
+package gov.cdc.izgw.v2tofhir.datatype;
import ca.uhn.hl7v2.model.Type;
+/**
+ * A parser for a FHIR Datatype
+ * @param The FHIR datatype parsed by this object.
+ *
+ * @author Audacious Inquiry
+ */
public interface DatatypeParser {
/**
* The name of the type this parser works on.
@@ -10,13 +16,16 @@ public interface DatatypeParser {
Class extends T> type();
/**
* Convert a string to a FHIR type.
+ *
* @param value The string to convert.
* @return An item of the specified type or null if the conversion could not be performed or when value is null or empty.
+ * @throws UnsupportedOperationException If this type cannot be converted from a string.
*/
- T fromString(String value);
+ T fromString(String value) throws UnsupportedOperationException;
/**
* Convert a V2 type to a FHIR type.
- * @param value The string to convert.
+ *
+ * @param type The V2 type to convert.
* @return An item of the required type or null if the conversion could not be performed or value is null or empty.
*/
T convert(Type type);
@@ -25,4 +34,5 @@ public interface DatatypeParser {
* @param fhirType The FHIR type to convert
* @return The HL7 v2 type.
*/
+ Type convert(T fhirType);
}
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/datatype/HumanNameParser.java b/src/main/java/gov/cdc/izgw/v2tofhir/datatype/HumanNameParser.java
new file mode 100644
index 0000000..3d1cdfd
--- /dev/null
+++ b/src/main/java/gov/cdc/izgw/v2tofhir/datatype/HumanNameParser.java
@@ -0,0 +1,334 @@
+package gov.cdc.izgw.v2tofhir.datatype;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.commons.lang3.StringUtils;
+import org.hl7.fhir.r4.model.HumanName;
+import org.hl7.fhir.r4.model.HumanName.NameUse;
+import org.hl7.fhir.r4.model.StringType;
+
+import ca.uhn.hl7v2.model.Composite;
+import ca.uhn.hl7v2.model.Primitive;
+import ca.uhn.hl7v2.model.Type;
+import gov.cdc.izgw.v2tofhir.converter.DatatypeConverter;
+import gov.cdc.izgw.v2tofhir.utils.ParserUtils;
+
+/**
+ * Parser for Human Names.
+ */
+public class HumanNameParser implements DatatypeParser {
+ /** Name prefixes appearing before the given name */
+ public static final Set PREFIXES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList("mr", "mrs", "miss", "sr", "br", "fr", "dr")));
+ /** Name suffixes appearing after the surname (family) name */
+ public static final Set SUFFIXES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList("jr", "sr", "i", "ii", "iii", "iv", "v")));
+ /** An affix is a prefix to a family name
+ * See https://en.wikipedia.org/wiki/List_of_family_name_affixes
+ */
+ public static final Set AFFIXES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList("Abu", "al", "bet", "bint", "el", "ibn", "ter",
+ "fer", "ait", "a\u00eft", "at", "ath", "de", "'s", "'t", "ter", "van", "vande", "vanden", "vander", "van't",
+ "von", "bath", "bat", "ben", "bin", "del", "degli", "della", "di", "a", "ab", "ap", "ferch", "verch",
+ "verch", "erch", "af", "alam", "\u0101lam", "bar", "ch", "chaudhary", "da", "das", "de", "dele", "dos",
+ "du", "e", "fitz", "i", "ka", "kil", "gil", "mal", "mul", "la", "le", "lu", "m'", "mac", "mc", "mck",
+ "mhic", "mic", "mala", "na", "nga", "ng\u0101", "nic", "ni", "n\u00ed", "nin", "o", "\u00f3", "ua", "ui",
+ "u\u00ed", "oz", "\u00f6z", "pour", "te", "tre", "war")));
+ /** Professional suffixes appearing after the surname (family) and any other suffixes */
+ public static final Set DEGREES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList("ab", "ba", "bs", " be", "bfa", "btech", "llb",
+ "bsc", "ma", "ms", "msc", "mfa", "llm", "mla", "mha", "mba", "msc", "meng", "mph", "mbi", "jd", "md", "do",
+ "pharmd", "dmin", "phd", "edd", "dphil", "dba", "lld", "engd", "esq", "rn", "lpn", "cna", "bsn", "msn",
+ "mss", "msw", "arpn", "np", "cnm", "cns", "crna", "dns", "dnp", "dsw", "mahs", "maop", "mc", "mdiv", "ddiv",
+ "psyd", "psyad", "scd", "abcfp", "abpn", "abpp", "acsw", "amft", "apcc", "aprn", "asw", "atr", "atrbc",
+ "bcdmt", "bcd", "catsm", "cbt", "ccc", "cccr", "cci", "cct", "cdt", "cdv", "cecr", "cft", "cit", "cmvt",
+ "cpm", "crt", "csa", "cscr", "csm", "cucr", "cwt", "cac", "cacad", "cadac", "cadc", "cags", "camf", "cap",
+ "cart", "cas", "casac", "cbt", "ccadc", "ccdp", "cch", "ccht", "ccmhc", "ccpt", "ccsw", "ceap", "ceds",
+ "cfle", "cgp", "cht", "cicsw", "cisw", "cmat", "cmft", "cmsw", "cp", "cpastc", "cpc", "cplc", "cradc",
+ "crc", "csac", "csat", "csw", "cswc", "dapa", "dcep", "dcsw", "dotsap", "fnpbc", "fnpc", "fhl7", "laadc", "lac",
+ "ladac", "ladc", "lamft", "lapc", "lasac", "lcadc", "lcas", "lcat", "lcdc", "lcdp", "lcmft", "lcmhc", "lcp",
+ "lcpc", "lcsw", "lcsw-c", "lgsw", "licsw", "limft", "limhp", "lisw", "lisw-cp", "llp", "lmft", "lmhc",
+ "lmhp", "lmsw", "lmswacp", "lp", "lpa", "lpastc", "lpc", "lpcc", "lpcmh", "lpe", "lpp", "lsatp", "lscsw",
+ "lsp", "lsw", "mac", "mfcc", "mft", "mtbc", "nbcch", "nbcdch", "ncc", "ncpsya", "ncsc", "ncsp", "pa",
+ "plmhp", "plpc", "pmhnp", "pmhnpbc", "pps", "ras", "rdmt", "rdt", "reat", "rn", "rpt", "rpts", "ryt", "sap",
+ "sep", "sw", "tllp"
+
+ )));
+
+ enum NamePart {
+ PREFIX, PREFIXANDSUFFIX, GIVEN, AFFIX, FAMILY, SUFFIX, NAME
+ }
+
+ /**
+ * Construct a HumanNameParser
+ */
+ public HumanNameParser() {
+ // Construct a default HumanNameParser
+ }
+
+ @Override
+ public Class type() {
+ return HumanName.class;
+ }
+
+ /**
+ * Parse a human name from a string. The parser recognizes common prefixes and
+ * suffixes and puts them in the prefix and suffix fields of the human name. It
+ * puts the first space separated string into the first given name, and any
+ * susbsequent strings except the last.
+ *
+ * @param name The name to parse
+ * @return The parsed name in a HumanName object
+ */
+ @Override
+ public HumanName fromString(String name) {
+ return computeFromString(name);
+ }
+
+ /**
+ * Parse a human name from a string. The parser recognizes common prefixes and
+ * suffixes and puts them in the prefix and suffix fields of the human name. It
+ * puts the first space separated string into the first given name, and any
+ * susbsequent strings except the last.
+ *
+ * @param name The name to parse
+ * @return The parsed name in a HumanName object
+ */
+ public static HumanName computeFromString(String name) {
+ HumanName hn = new HumanName();
+ hn.setText(name);
+ String[] parts = name.split("\\s");
+ StringBuilder familyName = new StringBuilder();
+ for (String part : parts) {
+ updateNamePart(hn, familyName, part);
+ }
+ List given = hn.getGiven();
+ if (familyName.isEmpty() && !given.isEmpty()) {
+ String family = given.get(given.size() - 1).toString();
+ hn.setFamily(family);
+ given.remove(given.size() - 1);
+ hn.setGiven(null);
+ for (StringType g : given) {
+ hn.addGiven(g.toString());
+ }
+ } else if (!familyName.isEmpty()) {
+ familyName.setLength(familyName.length() - 1); // Remove terminal " "
+ hn.setFamily(familyName.toString());
+ }
+ if (hn.isEmpty()) {
+ return null;
+ }
+ return hn;
+ }
+
+ private static void updateNamePart(HumanName hn, StringBuilder familyName, String part) {
+ NamePart classification = classifyNamePart(part);
+ switch (classification) {
+ case PREFIXANDSUFFIX:
+ if (!hn.hasGiven() && familyName.isEmpty()) {
+ hn.addPrefix(part);
+ return;
+ }
+ if (familyName.isEmpty()) {
+ // could be a prefix or a suffix, but we haven't yet started
+ // on suffixes and are beyond prefixes.
+ // Only thing that is both is "sr".
+ // We add it as a given name.
+ hn.addGiven(part);
+ return;
+ }
+ // Fall through to add suffix if we have some part of familyName
+ case SUFFIX:
+ hn.addSuffix(part);
+ return;
+ case PREFIX:
+ if (!hn.hasGiven() && familyName.isEmpty()) {
+ hn.addPrefix(part);
+ return;
+ }
+ familyName.append(part).append(" ");
+ return;
+ case AFFIX:
+ if (!hn.hasGiven()) {
+ hn.addGiven(part);
+ return;
+ }
+ familyName.append(part).append(" ");
+ return;
+ case NAME:
+ if (familyName.isEmpty()) {
+ hn.addGiven(part);
+ return;
+ }
+ familyName.append(part).append(" ");
+ return;
+ default:
+ return;
+ }
+ }
+
+ private static NamePart classifyNamePart(String part) {
+ if (isPrefix(part)) {
+ return isSuffix(part) ? NamePart.PREFIXANDSUFFIX : NamePart.PREFIX;
+ }
+ if (isAffix(part)) {
+ return NamePart.AFFIX;
+ }
+ if (isSuffix(part) || isDegree(part)) {
+ return NamePart.SUFFIX;
+ }
+ return NamePart.NAME;
+ }
+
+ @Override
+ public HumanName convert(Type t) {
+ t = DatatypeConverter.adjustIfVaries(t);
+ if (t instanceof Primitive pt) {
+ return fromString(pt.getValue());
+ }
+
+ if (t instanceof Composite comp) {
+ Type[] types = comp.getComponents();
+ switch (t.getName()) {
+ case "CNN":
+ return HumanNameParser.parse(types, 1, -1);
+ case "XCN":
+ HumanName hn = HumanNameParser.parse(types, 1, 9);
+ if (hn != null && types.length > 20 && types[20] != null) {
+ String suffix = ParserUtils.toString(types[20]);
+ hn.addSuffix(suffix);
+ }
+ return hn;
+ case "XPN":
+ return HumanNameParser.parse(types, 0, 6);
+ default:
+ break;
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public Type convert(HumanName name) {
+ return null;
+ }
+
+ static HumanName parse(Type[] types, int offset, int nameTypeLoc) {
+ HumanName hn = new HumanName();
+ for (int i = 0; i < 6; i++) {
+ String part = ParserUtils.toString(types[i + offset]);
+ if (StringUtils.isBlank(part)) {
+ continue;
+ }
+ switch (i) {
+ case 0: // Family Name (as ST in HL7 2.3)
+ hn.setFamily(part);
+ break;
+ case 1: // Given Name
+ hn.addGiven(part);
+ break;
+ case 2: // Second and Further Given Name or Initials
+ // Thereof
+ hn.addGiven(part);
+ break;
+ case 3: // Suffix
+ hn.addSuffix(part);
+ break;
+ case 4: // Prefix
+ hn.addPrefix(part);
+ break;
+ case 5: // Degree
+ hn.addSuffix(part);
+ break;
+ default:
+ break;
+ }
+ }
+ Type type = DatatypeConverter.adjustIfVaries(types, nameTypeLoc);
+
+ if (type instanceof Primitive pt) {
+ String nameType = pt.getValue();
+ hn.setUse(toNameUse(nameType));
+ }
+ if (hn.isEmpty()) {
+ return null;
+ }
+ return hn;
+ }
+
+ private static NameUse toNameUse(String nameType) {
+ if (nameType == null) {
+ return null;
+ }
+ switch (StringUtils.upperCase(nameType)) {
+ case "A": // Assigned
+ return NameUse.USUAL;
+ case "D": // Customary Name
+ return NameUse.USUAL;
+ case "L": // Official Registry Name
+ return NameUse.OFFICIAL;
+ case "M": // Maiden Name
+ return NameUse.MAIDEN;
+ case "N": // Nickname
+ return NameUse.NICKNAME;
+ case "NOUSE": // No Longer To Be Used
+ return NameUse.OLD;
+ case "R": // Registered Name
+ return NameUse.OFFICIAL;
+ case "TEMP": // Temporary Name
+ return NameUse.TEMP;
+ case "B", // Birth name
+ "BAD", // Bad Name
+ "C", // Adopted Name
+ "I", // Licensing Name
+ "K", // Business name
+ "MSK", // Masked
+ "NAV", // Temporarily Unavailable
+ "NB", // Newborn Name
+ "P", // Name of Partner/Spouse
+ "REL", // Religious
+ "S", // Pseudonym
+ "T", // Indigenous/Tribal
+ "U": // Unknown
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Sees if the string is an affix
+ * @param part The string to check
+ * @return True if it is an affix to the name.
+ */
+ public static boolean isAffix(String part) {
+ return AFFIXES.contains(StringUtils.lowerCase(part));
+ }
+
+ /**
+ * Sees if the string is an prefix
+ * @param part The string to check
+ * @return True if it is an prefix to the name.
+ */
+ public static boolean isPrefix(String part) {
+ return PREFIXES.contains(StringUtils.replace(StringUtils.lowerCase(part), ".", ""));
+ }
+
+ /**
+ * Sees if the string is an suffix
+ * @param part The string to check
+ * @return True if it is an suffix to the name.
+ */
+ public static boolean isSuffix(String part) {
+ return SUFFIXES.contains(StringUtils.replace(StringUtils.lowerCase(part), ".", ""));
+ }
+
+ /**
+ * Sees if the string is a degree (e.g., MD)
+ * @param part The string to check
+ * @return True if it is a degree following the name
+ */
+ public static boolean isDegree(String part) {
+ return DEGREES.contains(StringUtils.replace(StringUtils.lowerCase(part), ".", ""));
+ }
+
+}
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/datatype/package-info.java b/src/main/java/gov/cdc/izgw/v2tofhir/datatype/package-info.java
new file mode 100644
index 0000000..4d3a27d
--- /dev/null
+++ b/src/main/java/gov/cdc/izgw/v2tofhir/datatype/package-info.java
@@ -0,0 +1,5 @@
+/**
+ * This package contains the definition of a DatatypeParser interface, and datatype parsers for
+ * more complex datatypes, such as Address, ContactPoint and HumanName.
+ */
+package gov.cdc.izgw.v2tofhir.datatype;
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/package-info.java b/src/main/java/gov/cdc/izgw/v2tofhir/package-info.java
new file mode 100644
index 0000000..39b04db
--- /dev/null
+++ b/src/main/java/gov/cdc/izgw/v2tofhir/package-info.java
@@ -0,0 +1,23 @@
+/**
+ * The V2 to FHIR package contains the core code for the IZ Gateway Transformation Service
+ * components that perform conversion of HL7 V2 Immunization Messages written according to
+ * the CDC HL7 Version 2.5.1 Implementation Guide for Immunization Messaging to HL7 FHIR
+ * Resources conforming to FHIR R4, FHIR US Core V7.0.0, USCDI V4 and the HL7 Version 2 to FHIR
+ * Implementation Guide from the January 2024 STU Ballot.
+ *
+ * The converter package has everything a user needs to convert HL7 messages and components parsed
+ * using the HAPI V2 library into FHIR Resources.
+ *
+ * The segment package contains parsers for HL7 V2 message segments.
+ * The datatype package contains parsers for HL7 V2 datatypes.
+ * The utils package contains a variety of utility classes useful during the conversion process.
+ *
+ * @see CDC HL7 Version 2.5.1 Implementation Guide for Immunization Messaging
+ * @see FHIR US Core Implementation Guide V7.0.0
+ * @see USCDI V4
+ * @see HL7 Version 2 to FHIR
+ * @see Github
+ *
+ * @author Audacious Inquiry
+ */
+package gov.cdc.izgw.v2tofhir;
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/segment/AbstractSegmentParser.java b/src/main/java/gov/cdc/izgw/v2tofhir/segment/AbstractSegmentParser.java
new file mode 100644
index 0000000..985e943
--- /dev/null
+++ b/src/main/java/gov/cdc/izgw/v2tofhir/segment/AbstractSegmentParser.java
@@ -0,0 +1,71 @@
+package gov.cdc.izgw.v2tofhir.segment;
+
+import java.util.ServiceConfigurationError;
+
+import ca.uhn.hl7v2.HL7Exception;
+import ca.uhn.hl7v2.model.Segment;
+import ca.uhn.hl7v2.model.Structure;
+import gov.cdc.izgw.v2tofhir.converter.MessageParser;
+import lombok.extern.slf4j.Slf4j;
+
+
+/**
+ * A SegmentParser parses segments of the specified type.
+ *
+ * @author Audacious Inquiry
+ */
+@Slf4j
+public abstract class AbstractSegmentParser extends AbstractStructureParser {
+ /**
+ * Construct a segment parser for the specified message parser and segment type
+ * @param p The message parser
+ * @param s The segment type name
+ */
+ AbstractSegmentParser(MessageParser p, String s) {
+ super(p, s);
+ }
+
+ @Override
+ public void parse(Structure seg) throws HL7Exception {
+ if (seg instanceof Segment s) {
+ parse(s);
+ }
+ }
+
+ @Override
+ public void parse(Segment segment) {
+ if (isEmpty(segment)) {
+ return;
+ }
+
+ if (getProduces() == null) {
+ throw new ServiceConfigurationError(
+ "Missing @Produces on " + this.getClass().getSimpleName()
+ );
+ }
+ super.parse(segment);
+ }
+
+ /**
+ * Warn about a specific problem found while parsing.
+ *
+ * @see warnException
+ *
+ * @param msg The message format to use for the warning.
+ * @param args Arguments for the message. The last argument should still be part of the message.
+ */
+ void warn(String msg, Object ...args) {
+ log.warn(msg, args);
+ }
+
+ /**
+ * Warn about an exception found while parsing.
+ *
+ * @param msg The message format to use for the warning.
+ * @param args Arguments for the message. The last argument should be the exception found.
+ */
+ void warnException(String msg, Object ...args) {
+ log.warn(msg, args);
+ }
+
+}
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/segment/AbstractStructureParser.java b/src/main/java/gov/cdc/izgw/v2tofhir/segment/AbstractStructureParser.java
new file mode 100644
index 0000000..28deb98
--- /dev/null
+++ b/src/main/java/gov/cdc/izgw/v2tofhir/segment/AbstractStructureParser.java
@@ -0,0 +1,258 @@
+package gov.cdc.izgw.v2tofhir.segment;
+
+import java.util.List;
+
+import org.hl7.fhir.instance.model.api.IBaseResource;
+import org.hl7.fhir.r4.model.Bundle;
+import org.hl7.fhir.r4.model.OperationOutcome;
+import org.hl7.fhir.r4.model.Resource;
+import org.hl7.fhir.r4.model.OperationOutcome.OperationOutcomeIssueComponent;
+
+import ca.uhn.hl7v2.model.Segment;
+import gov.cdc.izgw.v2tofhir.annotation.Produces;
+import gov.cdc.izgw.v2tofhir.converter.Context;
+import gov.cdc.izgw.v2tofhir.converter.MessageParser;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * This is an abstract implementation for StructureParser instances used by the MessageParser.
+ *
+ * @author Audacious Inquiry
+ */
+@Slf4j
+public abstract class AbstractStructureParser implements StructureParser {
+ private Segment segment;
+ /**
+ * Returns the current segment being parsed.
+ * @return the current segment being parsed.
+ */
+ protected Segment getSegment() {
+ return segment;
+ }
+ /**
+ * Return what this parser produces.
+ * @return what this parser produces
+ */
+ protected Produces getProduces() {
+ return this.getClass().getAnnotation(Produces.class);
+ }
+
+ /**
+ * Subclasses must implement this method to get the field handlers.
+ * The list of field handers can be stored as a static member of the Parser
+ * class, and initialized once from a concrete instance of that class
+ * using initFieldHandlers.
+ *
+ * @return The list of Field Handlers
+ */
+ protected abstract List getFieldHandlers();
+
+ private final MessageParser messageParser;
+ private final String structureName;
+
+ /**
+ * Contruct a StructureParser for the given messageParser and
+ * @param messageParser
+ * @param segmentName
+ */
+ AbstractStructureParser(MessageParser messageParser, String structureName) {
+ this.messageParser = messageParser;
+ this.structureName = structureName;
+ }
+
+ @Override
+ public String structure() {
+ return structureName;
+ }
+
+ /**
+ * Return the message parser
+ * @return the message parser
+ */
+ public final MessageParser getMessageParser() {
+ return messageParser;
+ }
+
+ /**
+ * Returns the parsing context for the current message or structure being parsed.
+ *
+ * @return The parsing context
+ */
+ public Context getContext() {
+ return messageParser.getContext();
+ }
+
+ /**
+ * Get the bundle that is being prepared.
+ * This method can be used by parsers to get additional information from the Bundle
+ * @return The bundle that is being prepared during the parse.
+ */
+ public Bundle getBundle() {
+ return getContext().getBundle();
+ }
+
+ /**
+ * Get the first resource of the specified class that was created during the
+ * parsing of a message.
+ *
+ * This method is typically used to get resources that only appear once in a message,
+ * such as the MessageHeader, or Patient resource.
+ *
+ * @param The resource type.
+ * @param clazz The class for the specified resource.
+ * @return The first resource of the specified type, or null if not found.
+ */
+ public R getFirstResource(Class clazz) {
+ R r = messageParser.getFirstResource(clazz);
+ if (r == null) {
+ log.warn("No {} has been created", clazz.getSimpleName());
+ }
+ return r;
+ }
+
+ /**
+ * Get the most recently created resource of the specified type.
+ *
+ * This method is typically uses by a structure parser to get the most recently created
+ * resource in a group, such as the Immunization or ImmunizationRecommendation in an
+ * ORDER group in an RSP_K11 message.
+ *
+ * @param The type of resource to get.
+ * @param clazz The class for the specified resource.
+ * @return The last resource created of the specified type, or null if not found.
+ */
+ public R getLastResource(Class clazz) {
+ return messageParser.getLastResource(clazz);
+ }
+
+ /**
+ * Get the last issue in an OperationOutcome, adding one if there are none
+ * @param oo The OperationOutcome
+ * @return The issue
+ */
+ public OperationOutcomeIssueComponent getLastIssue(OperationOutcome oo) {
+ List l = oo.getIssue();
+ if (l.isEmpty()) {
+ return oo.getIssueFirstRep();
+ }
+ return l.get(l.size() - 1);
+ }
+
+ /**
+ * Get a resource of the specified type with the specified identifier.
+ * @param The type of resource
+ * @param clazz The specified type of the resource
+ * @param id The identifier (just the id part, no resource type or url
+ * @return The requested resource, or null if not found.
+ */
+ public R getResource(Class clazz, String id) {
+ return messageParser.getResource(clazz, id);
+ }
+ /**
+ * Get all resources of the specified type that have been created during this parsing session.
+ * @param The type of resource
+ * @param clazz The specified type of resource to retrieve
+ * @return A list of the requested resources, or an empty list of none were found.
+ */
+ public List getResources(Class clazz) {
+ return messageParser.getResources(clazz);
+ }
+
+ /**
+ * Create a resource of the specified type for this parsing session.
+ * This method is typically called by the first segment of a group that is associated with
+ * a given resource.
+ *
+ * @param The type of resource
+ * @param theClass The specified type for the resource to create
+ * @return A new resource of the specified type. The id will already be populated.
+ */
+ public R createResource(Class theClass) {
+ return messageParser.createResource(theClass, null);
+ }
+
+ /**
+ * Add a resource for this parsing session.
+ * This method is typically called to add resources that were created in some other
+ * way than from createResource(), e.g., from DatatypeConverter.to* methods
+ * that return a standalone resource.
+ *
+ * @param The type of resource
+ * @param resource The resource
+ * @return The resource
+ */
+ public R addResource(R resource) {
+ return messageParser.addResource(null, resource);
+ }
+ /**
+ * Find a resource of the specified type for this parsing session, or create one if none
+ * can be found or id is null or empty.
+ *
+ * This method is typically called by the first segment of a group that is associated with
+ * a given resource. It can be used to create a resource with a given id value if control
+ * is needed over the id value.
+ *
+ * @param The type of resource
+ * @param clazz The sepecified type for the resource to create
+ * @param id The identifier to assign.
+ * @return A new resource of the specified type. The id will already be populated.
+ */
+ public R findResource(Class clazz, String id) {
+ return messageParser.findResource(clazz, id);
+ }
+
+ /**
+ * Get a property value from the context.
+ *
+ * This is simply a convenience method for calling getContext().getProperty(t).
+ *
+ * @param The type of property to retrieve.
+ * @param t The class type of the property
+ * @return The requested property, or null if not present
+ */
+ public T getProperty(Class t) {
+ return messageParser.getContext().getProperty(t);
+ }
+
+ /**
+ * Annotation driven parsing. Call this method to use parsing driven
+ * by ComesFrom and Produces annotations in the parser.
+ *
+ * This method will be called by MessageParser for each segment of the given type that
+ * appears within the method. It will create the primary resource produced by the
+ * parser (by calling the setup() method) and then parses individual fields of the
+ * segment and passes them to parser methods to add them to the primary resource or
+ * to create any extra resources.
+ *
+ * @param segment The segment to be parsed
+ */
+ public void parse(Segment segment) {
+ if (isEmpty(segment)) {
+ return;
+ }
+
+ this.segment = segment;
+ IBaseResource r = setup();
+ if (r == null) {
+ // setup() returned nothing, there must be nothing to do
+ return;
+ }
+ List handlers = getFieldHandlers();
+ for (FieldHandler fieldHandler : handlers) {
+ fieldHandler.handle(this, segment, r);
+ }
+ }
+
+ /**
+ * Set up any resources that field handlers will use. Used during
+ * initialization of field handlers as well as during a normal
+ * parse operation.
+ *
+ * NOTE: Setup can look at previously parsed items to determine if there is
+ * any work to do in the context of the current message.
+ *
+ * @return the primary resource being created by this parser, or
+ * null if parsing in this context should do nothing.
+ */
+ public abstract IBaseResource setup();
+}
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/segment/DSCParser.java b/src/main/java/gov/cdc/izgw/v2tofhir/segment/DSCParser.java
new file mode 100644
index 0000000..991d4d8
--- /dev/null
+++ b/src/main/java/gov/cdc/izgw/v2tofhir/segment/DSCParser.java
@@ -0,0 +1,58 @@
+package gov.cdc.izgw.v2tofhir.segment;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.hl7.fhir.instance.model.api.IBaseResource;
+import org.hl7.fhir.r4.model.Parameters;
+
+import ca.uhn.hl7v2.model.Segment;
+import gov.cdc.izgw.v2tofhir.annotation.Produces;
+import gov.cdc.izgw.v2tofhir.converter.MessageParser;
+import gov.cdc.izgw.v2tofhir.utils.ParserUtils;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * The QID Parser extracts the query tag and query name from the QID segment
+ * and attaches them to the Parameters resource.
+ *
+ * @author Audacious Inquiry
+ *
+ */
+@Produces(segment="DSC", resource=Parameters.class)
+@Slf4j
+public class DSCParser extends AbstractSegmentParser {
+ private Parameters params;
+ private static List fieldHandlers = new ArrayList<>();
+ static {
+ log.debug("{} loaded", RCPParser.class.getName());
+ }
+ /**
+ * Construct a new DSC Parser for the given messageParser.
+ *
+ * @param messageParser The messageParser using this QPDParser
+ */
+ public DSCParser(MessageParser messageParser) {
+ super(messageParser, "DSC");
+ if (fieldHandlers.isEmpty()) {
+ FieldHandler.initFieldHandlers(this, fieldHandlers);
+ }
+ }
+
+ @Override
+ public List getFieldHandlers() {
+ return fieldHandlers;
+ }
+
+ public IBaseResource setup() {
+ params = createResource(Parameters.class);
+ return params;
+ }
+
+ @Override
+ public void parse(Segment qid) {
+ setup();
+ params.addParameter("Pointer", ParserUtils.toString(qid, 1));
+ params.addParameter("Style", ParserUtils.toString(qid, 2));
+ }
+}
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/segment/ERRParser.java b/src/main/java/gov/cdc/izgw/v2tofhir/segment/ERRParser.java
new file mode 100644
index 0000000..b9c02ec
--- /dev/null
+++ b/src/main/java/gov/cdc/izgw/v2tofhir/segment/ERRParser.java
@@ -0,0 +1,201 @@
+package gov.cdc.izgw.v2tofhir.segment;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+
+import org.hl7.fhir.exceptions.FHIRException;
+import org.hl7.fhir.instance.model.api.IBaseResource;
+import org.hl7.fhir.r4.model.CodeableConcept;
+import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.MessageHeader;
+import org.hl7.fhir.r4.model.OperationOutcome;
+import org.hl7.fhir.r4.model.StringType;
+import org.hl7.fhir.r4.model.OperationOutcome.IssueSeverity;
+import org.hl7.fhir.r4.model.OperationOutcome.IssueType;
+import org.hl7.fhir.r4.model.OperationOutcome.OperationOutcomeIssueComponent;
+
+import gov.cdc.izgw.v2tofhir.annotation.ComesFrom;
+import gov.cdc.izgw.v2tofhir.annotation.Produces;
+import gov.cdc.izgw.v2tofhir.converter.MessageParser;
+import gov.cdc.izgw.v2tofhir.utils.Mapping;
+import gov.cdc.izgw.v2tofhir.utils.ParserUtils;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Parser for an ERR segment.
+ *
+ * @author Audacious Inquiry
+ */
+
+/**
+ * Parse an ERR segment into an OperationOutcome resource.
+ *
+ * If no OperationOutcome already exists for this message, a new one is created.
+ * One OperationOutcome.issue is created for each ERR segment.
+ *
+ * OperationOutcome.issue.location is set from ERR-2
+ * OperationOutcome.issue.code is set from ERR-3
+ * OperationOutcome.issue.severity is set from ERR-4
+ * OperationOutcome.issue.detail is set from ERR-3 and ERR-5
+ * OperationOutcome.issue.diagnostics is set from ERR-7 and ERR-8 if present.
+ *
+ */
+@Produces(segment="ERR", resource = OperationOutcome.class, extra = MessageHeader.class)
+@Slf4j
+public class ERRParser extends AbstractSegmentParser {
+ private static final String SUCCESS = "success";
+ private OperationOutcomeIssueComponent issue;
+
+ static {
+ log.debug("{} loaded", ERRParser.class.getName());
+ }
+ private static final LinkedHashMap errorCodeMap = new LinkedHashMap<>();
+ private static final String NOT_SUPPORTED = "not-supported";
+ private static final String[][] errorCodeMapping = {
+ { SUCCESS, "0", "Message accepted", "Success. Optional, as the AA conveys success. Used for systems that must always return a status code." },
+ { "structure", "100", "Segment sequence error", "Error: The message segments were not in the proper order, or required segments are missing." },
+ { "required", "101", "Required field missing", "Error: A required field is missing from a segment" },
+ { "value", "102", "Data type error", "Error: The field contained data of the wrong data type, e.g. an NM field contained" },
+ { "code-invalid", "103", "Table value not found", "Error: A field of data type ID or IS was compared against the corresponding table, and no match was found." },
+ { NOT_SUPPORTED, "200", "Unsupported message type", "Rejection: The Message Type is not supported." },
+ { NOT_SUPPORTED, "201", "Unsupported event code", "Rejection: The Event Code is not supported." },
+ { NOT_SUPPORTED, "202", "Unsupported processing id", "Rejection: The Processing ID is not supported." },
+ { NOT_SUPPORTED, "203", "Unsupported version id", "Rejection: The Version ID is not supported." },
+ { "not-found", "204", "Unknown key identifier", "Rejection: The ID of the patient, order, etc., was not found. Used for transactions other than additions, e.g. transfer of a non-existent patient." },
+ { "conflict", "205", "Duplicate key identifier", "Rejection: The ID of the patient, order, etc., already exists. Used in response to addition transactions (Admit, New Order, etc.)." },
+ { "lock-error", "206", "Application record locked", "Rejection: The transaction could not be performed at the application storage level, e.g., database locked." },
+ { "exception", "207", "Application internal error", "Rejection: A catchall for internal errors not explicitly covered by other codes." }
+ };
+ private static final List fieldHandlers = new ArrayList<>();
+ static {
+ for (String[] mapping : errorCodeMapping) {
+ errorCodeMap.put(mapping[1], mapping);
+ }
+ }
+
+ /**
+ * Construct the ERR Segment parser for the given MessageParser
+ *
+ * @param messageParser The messageParser that is using this segment parser.
+ */
+ public ERRParser(MessageParser messageParser) {
+ super(messageParser, "ERR");
+ if (fieldHandlers.isEmpty()) {
+ FieldHandler.initFieldHandlers(this, fieldHandlers);
+ }
+ }
+
+ @Override
+ public List getFieldHandlers() {
+ return fieldHandlers;
+ }
+
+ @Override
+ public IBaseResource setup() {
+ OperationOutcome oo;
+ // Create a new OperationOutcome.issue for each ERR resource
+ oo = getFirstResource(OperationOutcome.class);
+ if (oo == null) {
+ oo = createResource(OperationOutcome.class);
+ MessageHeader mh = getFirstResource(MessageHeader.class);
+ if (mh == null) {
+ mh = createResource(MessageHeader.class);
+ }
+ // If MessageHeader or OperationOutcome has to be created,
+ // link them in MessageHeader.response.details
+ // Normally this is already done in MSAParser, but if processing
+ // an incomplete message (e.g., ERR segment but w/o MSA), this ensures
+ // that the parser has the expected objects to work with.
+ mh.getResponse().setDetails(ParserUtils.toReference(oo, mh, "details"));
+ }
+ issue = oo.addIssue();
+ return oo;
+ }
+
+ /**
+ * Add the error location to the operation outcome.
+ * @param location The location
+ */
+ @ComesFrom(path = "OperationOutcome.issue.location", field = 2)
+ public void setLocation(StringType location) {
+ issue.getLocation().add(location);
+ }
+
+ /**
+ * Set the details on an operation outcome
+ * @param errorCode The error code
+ */
+ @ComesFrom(path="OperationOutcome.issue.details", table="0357", field=3, comment="Also sets issue.code")
+ @ComesFrom(path="OperationOutcome.issue.details", table="0533", field=5)
+ public void setDetails(CodeableConcept errorCode) {
+ if (errorCode == null) {
+ return;
+ }
+ for (Coding coding: errorCode.getCoding()) {
+ issue.getDetails().addCoding(coding);
+ }
+ for (Coding c: errorCode.getCoding()) {
+ // If from table 0357
+ if (!Mapping.v2Table("0357").equals(c.getSystem())) { // Check the system.
+ continue;
+ }
+ String[] map = errorCodeMap.get(c.getCode());
+ if (map == null) {
+ issue.setCode(IssueType.PROCESSING);
+ continue;
+ }
+ try {
+ if (SUCCESS.equals(map[0])) {
+ // Some versions of FHIR support "success", others don't.
+ issue.setCode(IssueType.INFORMATIONAL);
+ } else {
+ IssueType type = IssueType.fromCode(map[0]);
+ issue.setCode(type);
+ }
+ } catch (FHIRException fex) {
+ warnException("Unexpected FHIRException: {}", fex.getMessage(), fex);
+ }
+ }
+ }
+
+ /**
+ * Set the severity
+ * @param severity Severity
+ */
+ @ComesFrom(path="OperationOutcome.issue.severity", field = 4, table = "0516", map = "ErrorSeverity")
+ public void setSeverity(CodeableConcept severity) {
+ for (Coding coding: severity.getCoding()) {
+ issue.getDetails().addCoding(coding);
+ }
+ for (Coding sev : severity.getCoding()) {
+ if (sev == null || !sev.hasCode()) {
+ continue;
+ }
+ switch (sev.getCode().toUpperCase()) {
+ // F is not really a code in table 0516, but if it shows up, treat it as if it meant fatal.
+ case "F": issue.setSeverity(IssueSeverity.FATAL); break;
+ // These are the legit codes.
+ case "I": issue.setSeverity(IssueSeverity.INFORMATION); break;
+ case "W": issue.setSeverity(IssueSeverity.WARNING); break;
+ case "E": // Fall through
+ // Everything else maps to error
+ default: issue.setSeverity(IssueSeverity.ERROR); break;
+ }
+ }
+ }
+
+ /**
+ * Set the diagnostic messages in MSA-7 and MSA-8 in the OperationOutcome
+ * @param diagnostics The diagnostics to report
+ */
+ @ComesFrom(path="OperationOutcome.issue.diagnostics", field=7, comment="Diagnostics")
+ @ComesFrom(path="OperationOutcome.issue.diagnostics", field=8, comment="User Message")
+ public void setDiagnostics(StringType diagnostics) {
+ if (issue.hasDiagnostics()) {
+ issue.setDiagnostics(issue.getDiagnostics() + "\n" + diagnostics);
+ } else {
+ issue.setDiagnosticsElement(diagnostics);
+ }
+ }
+}
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/segment/EVNParser.java b/src/main/java/gov/cdc/izgw/v2tofhir/segment/EVNParser.java
new file mode 100644
index 0000000..4e3cc7c
--- /dev/null
+++ b/src/main/java/gov/cdc/izgw/v2tofhir/segment/EVNParser.java
@@ -0,0 +1,115 @@
+package gov.cdc.izgw.v2tofhir.segment;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.hl7.fhir.instance.model.api.IBaseResource;
+import org.hl7.fhir.r4.model.CodeableConcept;
+import org.hl7.fhir.r4.model.DateTimeType;
+import org.hl7.fhir.r4.model.Identifier;
+import org.hl7.fhir.r4.model.InstantType;
+import org.hl7.fhir.r4.model.Location;
+import org.hl7.fhir.r4.model.MessageHeader;
+import org.hl7.fhir.r4.model.Practitioner;
+import org.hl7.fhir.r4.model.Provenance;
+
+import gov.cdc.izgw.v2tofhir.annotation.ComesFrom;
+import gov.cdc.izgw.v2tofhir.annotation.Produces;
+import gov.cdc.izgw.v2tofhir.converter.MessageParser;
+import gov.cdc.izgw.v2tofhir.utils.ParserUtils;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * The EVNParser extracts information from the EVN segment and adds it to the
+ * ProvenanceResource for the MessageHeader.
+ *
+ * @author Audacious Inquiry
+ *
+ */
+@Produces(segment = "EVN", resource = Provenance.class)
+@Slf4j
+public class EVNParser extends AbstractSegmentParser {
+ Provenance provenance;
+ private static List fieldHandlers = new ArrayList<>();
+ static {
+ log.debug("{} loaded", EVNParser.class.getName());
+ }
+
+ /**
+ * Construct a new DSC Parser for the given messageParser.
+ *
+ * @param messageParser The messageParser using this QPDParser
+ */
+ public EVNParser(MessageParser messageParser) {
+ super(messageParser, "EVN");
+ if (fieldHandlers.isEmpty()) {
+ FieldHandler.initFieldHandlers(this, fieldHandlers);
+ }
+ }
+
+ @Override
+ public List getFieldHandlers() {
+ return fieldHandlers;
+ }
+
+ public IBaseResource setup() {
+ MessageHeader mh = getFirstResource(MessageHeader.class);
+ if (mh != null) {
+ provenance = (Provenance) mh.getUserData(Provenance.class.getName());
+ } else {
+ // Create a standalone provenance resource for segment testing
+ provenance = createResource(Provenance.class);
+ }
+ return provenance;
+ }
+
+ /**
+ * Set the recorded date time.
+ * @param recordedDateTime The time the event was recorded.
+ */
+ @ComesFrom(path = "Provenance.recorded", field = 2, comment = "Recorded Date/Time")
+ public void setRecordedDateTime(InstantType recordedDateTime) {
+ provenance.setRecordedElement(recordedDateTime);
+ }
+
+ /**
+ * Set the reason for the event
+ * @param eventReasonCode the reason for the event
+ */
+ @ComesFrom(path = "Provenance.reason", field = 4, comment = "Event Reason Code")
+ public void setEventReasonCode(CodeableConcept eventReasonCode) {
+ provenance.addReason(eventReasonCode);
+ }
+
+ /**
+ * Set the operator
+ * @param operatorId The operator
+ */
+ @ComesFrom(path = "Provenance.agent.who.Practitioner", field = 5, comment = "Operator ID")
+ public void setOperatorID(Practitioner operatorId) {
+ provenance.addAgent().setWho(ParserUtils.toReference(operatorId, provenance, "agent"));
+ }
+
+ /**
+ * Set the date/time of the event
+ * @param eventOccurred the occurence date/time of the event
+ */
+ @ComesFrom(path = "Provenance.occurredDateTime", field = 6, comment = "Event Occurred")
+ public void setEventOccurred(DateTimeType eventOccurred) {
+ provenance.setOccurred(eventOccurred);
+ }
+
+ /**
+ * Set the identifier of the facility where the event occurred
+ * @param eventFacility The event facility identifier
+ */
+ @ComesFrom(path = "Provenance.location", field = 7, comment = "Event Facility")
+ public void setEventFacility(Identifier eventFacility) {
+ if (eventFacility.hasSystem()) {
+ Location location = createResource(Location.class);
+ location.setName(eventFacility.getSystem());
+ location.addIdentifier(eventFacility);
+ provenance.setLocation(ParserUtils.toReference(location, provenance, "location"));
+ }
+ }
+}
diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/segment/FieldHandler.java b/src/main/java/gov/cdc/izgw/v2tofhir/segment/FieldHandler.java
new file mode 100644
index 0000000..3236cff
--- /dev/null
+++ b/src/main/java/gov/cdc/izgw/v2tofhir/segment/FieldHandler.java
@@ -0,0 +1,374 @@
+package gov.cdc.izgw.v2tofhir.segment;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.ServiceConfigurationError;
+
+import org.apache.commons.lang3.StringUtils;
+import org.hl7.fhir.instance.model.api.IBase;
+import org.hl7.fhir.instance.model.api.IBaseResource;
+
+import com.ainq.fhir.utils.PathUtils;
+import com.ainq.fhir.utils.Property;
+
+import ca.uhn.hl7v2.model.Composite;
+import ca.uhn.hl7v2.model.DataTypeException;
+import ca.uhn.hl7v2.model.Segment;
+import ca.uhn.hl7v2.model.Type;
+import ca.uhn.hl7v2.model.v251.datatype.ST;
+import gov.cdc.izgw.v2tofhir.annotation.ComesFrom;
+import gov.cdc.izgw.v2tofhir.annotation.Produces;
+import gov.cdc.izgw.v2tofhir.converter.DatatypeConverter;
+import gov.cdc.izgw.v2tofhir.utils.ParserUtils;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * A handler for processing field conversions in a Segment of a V2 message.
+ *
+ * @author Audacious Inquiry
+ */
+@Slf4j
+public class FieldHandler implements Comparable