diff --git a/sip-codec/src/main/java/com/sip/codec/typed/CSeqParser.java b/sip-codec/src/main/java/com/sip/codec/typed/CSeqParser.java
new file mode 100644
index 0000000..c884770
--- /dev/null
+++ b/sip-codec/src/main/java/com/sip/codec/typed/CSeqParser.java
@@ -0,0 +1,82 @@
+package com.sip.codec.typed;
+
+import com.sip.codec.SipCodecException;
+import com.sip.message.SipMethod;
+import com.sip.message.header.HeaderName;
+import com.sip.message.header.Headers;
+import com.sip.message.header.RawHeader;
+import com.sip.message.header.typed.CSeqHeader;
+
+import java.util.Optional;
+
+/**
+ * Parses the CSeq header (RFC 3261 §20.16):
+ *
+ * CSeq = 1*DIGIT LWS Method
+ *
+ */
+public final class CSeqParser {
+
+ private CSeqParser() { }
+
+ public static Optional parse(Headers headers) {
+ return headers.first(HeaderName.CSEQ).map(CSeqParser::parse);
+ }
+
+ public static CSeqHeader parse(RawHeader raw) {
+ return parse(raw.value());
+ }
+
+ public static CSeqHeader parse(String text) {
+ if (text == null) {
+ throw malformed("CSeq is null");
+ }
+ String s = text.trim();
+ int sp = -1;
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (c == ' ' || c == '\t') {
+ sp = i;
+ break;
+ }
+ }
+ if (sp <= 0) {
+ throw malformed("CSeq missing whitespace between sequence and method: '" + s + "'");
+ }
+ String seqText = s.substring(0, sp);
+ long sequence;
+ try {
+ sequence = Long.parseLong(seqText);
+ } catch (NumberFormatException e) {
+ throw malformed("CSeq sequence is not numeric: '" + seqText + "'", e);
+ }
+ if (sequence < 0 || sequence > CSeqHeader.MAX_SEQUENCE) {
+ throw malformed("CSeq sequence out of range [0," + CSeqHeader.MAX_SEQUENCE
+ + "]: " + sequence);
+ }
+ int methodStart = sp;
+ while (methodStart < s.length()
+ && (s.charAt(methodStart) == ' ' || s.charAt(methodStart) == '\t')) {
+ methodStart++;
+ }
+ if (methodStart == s.length()) {
+ throw malformed("CSeq missing Method token");
+ }
+ String methodText = s.substring(methodStart).trim();
+ try {
+ return new CSeqHeader(sequence, SipMethod.of(methodText));
+ } catch (IllegalArgumentException e) {
+ throw malformed("CSeq Method invalid: " + e.getMessage(), e);
+ }
+ }
+
+ private static SipCodecException malformed(String message) {
+ return new SipCodecException(
+ SipCodecException.Category.MALFORMED_HEADER, -1, message);
+ }
+
+ private static SipCodecException malformed(String message, Throwable cause) {
+ return new SipCodecException(
+ SipCodecException.Category.MALFORMED_HEADER, -1, message, cause);
+ }
+}
diff --git a/sip-codec/src/main/java/com/sip/codec/typed/CallIdValidator.java b/sip-codec/src/main/java/com/sip/codec/typed/CallIdValidator.java
new file mode 100644
index 0000000..b522789
--- /dev/null
+++ b/sip-codec/src/main/java/com/sip/codec/typed/CallIdValidator.java
@@ -0,0 +1,59 @@
+package com.sip.codec.typed;
+
+import com.sip.codec.SipCodecException;
+import com.sip.message.header.HeaderName;
+import com.sip.message.header.Headers;
+
+import java.util.Optional;
+
+/**
+ * Validates Call-ID per RFC 3261 §20.8 / §25:
+ *
+ * Call-ID = word [ "@" word ]
+ * word = 1*(alphanum / "-" / "." / "!" / "%" / "*" / "_" / "+"
+ * / "`" / "'" / "~" / "(" / ")" / "<" / ">"
+ * / ":" / "\" / DQUOTE / "/" / "[" / "]" / "?" / "{" / "}")
+ *
+ *
+ * Returns the validated string (after trimming surrounding LWS) for use
+ * as a dialog identity component.
+ */
+public final class CallIdValidator {
+
+ private CallIdValidator() { }
+
+ public static Optional get(Headers headers) {
+ return headers.first(HeaderName.CALL_ID).map(r -> validate(r.value()));
+ }
+
+ public static String validate(String text) {
+ String s = text == null ? "" : text.trim();
+ if (s.isEmpty()) {
+ throw malformed("Call-ID is empty");
+ }
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (!isWordChar(c) && c != '@') {
+ throw malformed("Call-ID contains invalid character at offset " + i
+ + ": '" + c + "' (0x" + Integer.toHexString(c) + ")");
+ }
+ }
+ return s;
+ }
+
+ private static boolean isWordChar(char c) {
+ if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
+ return true;
+ }
+ return switch (c) {
+ case '-', '.', '!', '%', '*', '_', '+', '`', '\'', '~',
+ '(', ')', '<', '>', ':', '\\', '"', '/', '[', ']', '?', '{', '}' -> true;
+ default -> false;
+ };
+ }
+
+ private static SipCodecException malformed(String message) {
+ return new SipCodecException(
+ SipCodecException.Category.MALFORMED_HEADER, -1, message);
+ }
+}
diff --git a/sip-codec/src/main/java/com/sip/codec/typed/ContactParser.java b/sip-codec/src/main/java/com/sip/codec/typed/ContactParser.java
new file mode 100644
index 0000000..e8bfeba
--- /dev/null
+++ b/sip-codec/src/main/java/com/sip/codec/typed/ContactParser.java
@@ -0,0 +1,53 @@
+package com.sip.codec.typed;
+
+import com.sip.codec.SipCodecException;
+import com.sip.message.header.HeaderName;
+import com.sip.message.header.Headers;
+import com.sip.message.header.RawHeader;
+import com.sip.message.header.typed.ContactValue;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Parses Contact header values (RFC 3261 §20.10):
+ *
+ *
+ * Contact = ("Contact" / "m") HCOLON
+ * ( STAR / (contact-param *(COMMA contact-param)) )
+ * contact-param = (name-addr / addr-spec) *(SEMI contact-params)
+ *
+ *
+ * Returns either a single wildcard entry or a list of named contacts.
+ */
+public final class ContactParser {
+
+ private ContactParser() { }
+
+ public static List parseAll(Headers headers) {
+ List out = new ArrayList<>();
+ boolean sawWildcard = false;
+ for (RawHeader raw : headers.all(HeaderName.CONTACT)) {
+ String v = raw.value().trim();
+ if ("*".equals(v)) {
+ if (!out.isEmpty()) {
+ throw new SipCodecException(
+ SipCodecException.Category.MALFORMED_HEADER, -1,
+ "Contact: '*' cannot be combined with other contact values");
+ }
+ sawWildcard = true;
+ out.add(ContactValue.wildcard());
+ continue;
+ }
+ if (sawWildcard) {
+ throw new SipCodecException(
+ SipCodecException.Category.MALFORMED_HEADER, -1,
+ "additional Contact values after wildcard '*'");
+ }
+ for (var na : NameAddrParser.parseList(v)) {
+ out.add(ContactValue.of(na));
+ }
+ }
+ return out;
+ }
+}
diff --git a/sip-codec/src/main/java/com/sip/codec/typed/IntegerHeaderParser.java b/sip-codec/src/main/java/com/sip/codec/typed/IntegerHeaderParser.java
new file mode 100644
index 0000000..768a6f5
--- /dev/null
+++ b/sip-codec/src/main/java/com/sip/codec/typed/IntegerHeaderParser.java
@@ -0,0 +1,48 @@
+package com.sip.codec.typed;
+
+import com.sip.codec.SipCodecException;
+import com.sip.message.header.HeaderName;
+import com.sip.message.header.Headers;
+
+import java.util.Optional;
+
+/**
+ * Helpers for simple integer headers (Max-Forwards, Expires, Content-Length).
+ */
+public final class IntegerHeaderParser {
+
+ private IntegerHeaderParser() { }
+
+ public static Optional maxForwards(Headers h) {
+ return h.first(HeaderName.MAX_FORWARDS).map(r -> parseInt(r.value(), 0, 255,
+ "Max-Forwards"));
+ }
+
+ public static Optional expires(Headers h) {
+ return h.first(HeaderName.EXPIRES).map(r -> parseInt(r.value(), 0, Integer.MAX_VALUE,
+ "Expires"));
+ }
+
+ public static Optional contentLength(Headers h) {
+ return h.first(HeaderName.CONTENT_LENGTH).map(r -> parseInt(r.value(), 0,
+ Integer.MAX_VALUE, "Content-Length"));
+ }
+
+ static int parseInt(String text, int min, int max, String headerName) {
+ String s = text.trim();
+ int v;
+ try {
+ v = Integer.parseInt(s);
+ } catch (NumberFormatException e) {
+ throw new SipCodecException(
+ SipCodecException.Category.MALFORMED_HEADER, -1,
+ headerName + " is not an integer: '" + s + "'", e);
+ }
+ if (v < min || v > max) {
+ throw new SipCodecException(
+ SipCodecException.Category.MALFORMED_HEADER, -1,
+ headerName + " out of range [" + min + "," + max + "]: " + v);
+ }
+ return v;
+ }
+}
diff --git a/sip-codec/src/main/java/com/sip/codec/typed/NameAddrParser.java b/sip-codec/src/main/java/com/sip/codec/typed/NameAddrParser.java
new file mode 100644
index 0000000..07168f7
--- /dev/null
+++ b/sip-codec/src/main/java/com/sip/codec/typed/NameAddrParser.java
@@ -0,0 +1,168 @@
+package com.sip.codec.typed;
+
+import com.sip.codec.SipCodecException;
+import com.sip.codec.SipUriParser;
+import com.sip.message.header.typed.NameAddr;
+import com.sip.message.uri.Uri;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Parses a single {@code name-addr} or {@code addr-spec} (RFC 3261 §25)
+ * followed by zero or more header parameters.
+ *
+ *
+ * name-addr = [ display-name ] LAQUOT addr-spec RAQUOT
+ * addr-spec = SIP-URI / SIPS-URI / absoluteURI
+ * from-spec = ( name-addr / addr-spec ) *( SEMI from-param )
+ *
+ *
+ * The parser is tolerant of several minor irregularities described in
+ * RFC 4475 §3.1.1 — most importantly, missing LWS between the display-name
+ * and the {@code <} (RFC 4475 §3.1.1.6 / {@code lwsdisp}).
+ */
+public final class NameAddrParser {
+
+ private NameAddrParser() { }
+
+ /** Parses one {@code name-addr [params]} value. */
+ public static NameAddr parseSingle(String text) {
+ if (text == null) {
+ throw malformed("empty name-addr");
+ }
+ String s = text.trim();
+ if (s.isEmpty()) {
+ throw malformed("empty name-addr");
+ }
+
+ String displayName = null;
+ Uri uri;
+ String paramsText = null;
+
+ if (s.startsWith("\"")) {
+ // Quoted display name.
+ int closing = findClosingQuote(s, 1);
+ if (closing < 0) {
+ throw malformed("unterminated quoted-string in display-name: '" + s + "'");
+ }
+ displayName = unescape(s.substring(1, closing));
+ int laquot = s.indexOf('<', closing + 1);
+ if (laquot < 0) {
+ throw malformed("display-name missing '' after quoted string");
+ }
+ int raquot = s.indexOf('>', laquot + 1);
+ if (raquot < 0) {
+ throw malformed("missing '>' after addr-spec");
+ }
+ uri = SipUriParser.parse(s.substring(laquot + 1, raquot).trim());
+ if (raquot + 1 < s.length() && s.charAt(raquot + 1) == ';') {
+ paramsText = s.substring(raquot + 2);
+ }
+ } else if (s.startsWith("<")) {
+ // Bare addr-spec in angle brackets.
+ int raquot = s.indexOf('>', 1);
+ if (raquot < 0) {
+ throw malformed("missing '>' after addr-spec");
+ }
+ uri = SipUriParser.parse(s.substring(1, raquot).trim());
+ if (raquot + 1 < s.length() && s.charAt(raquot + 1) == ';') {
+ paramsText = s.substring(raquot + 2);
+ }
+ } else {
+ int laquot = s.indexOf('<');
+ if (laquot >= 0) {
+ // Token display-name followed by ''. Allows zero-LWS form
+ // (RFC 4475 §3.1.1.6 lwsdisp).
+ displayName = s.substring(0, laquot).trim();
+ if (displayName.isEmpty()) {
+ displayName = null;
+ }
+ int raquot = s.indexOf('>', laquot + 1);
+ if (raquot < 0) {
+ throw malformed("missing '>' after addr-spec");
+ }
+ uri = SipUriParser.parse(s.substring(laquot + 1, raquot).trim());
+ if (raquot + 1 < s.length() && s.charAt(raquot + 1) == ';') {
+ paramsText = s.substring(raquot + 2);
+ }
+ } else {
+ // Bare addr-spec form: split off optional ;params.
+ int semi = s.indexOf(';');
+ String uriText = (semi < 0) ? s : s.substring(0, semi);
+ uri = SipUriParser.parse(uriText.trim());
+ if (semi >= 0) {
+ paramsText = s.substring(semi + 1);
+ }
+ }
+ }
+
+ Map params = new LinkedHashMap<>();
+ if (paramsText != null && !paramsText.isEmpty()) {
+ for (String p : ViaParser.splitParams(paramsText)) {
+ int eq = p.indexOf('=');
+ if (eq < 0) {
+ String name = p.trim();
+ if (name.isEmpty()) {
+ throw malformed("empty parameter token in '" + paramsText + "'");
+ }
+ params.put(name, "");
+ } else {
+ String name = p.substring(0, eq).trim();
+ String value = p.substring(eq + 1).trim();
+ if (name.isEmpty()) {
+ throw malformed("empty parameter name in '" + p + "'");
+ }
+ params.put(name, value);
+ }
+ }
+ }
+ return new NameAddr(java.util.Optional.ofNullable(displayName), uri, params);
+ }
+
+ /** Parses a comma-separated list of {@code name-addr [params]} values. */
+ public static List parseList(String text) {
+ List out = new ArrayList<>();
+ for (String part : ViaParser.splitTopLevelCommas(text)) {
+ String trimmed = part.trim();
+ if (!trimmed.isEmpty()) {
+ out.add(parseSingle(trimmed));
+ }
+ }
+ return out;
+ }
+
+ private static int findClosingQuote(String s, int from) {
+ for (int i = from; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (c == '\\') {
+ i++; // skip escaped char
+ continue;
+ }
+ if (c == '"') {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private static String unescape(String s) {
+ StringBuilder sb = new StringBuilder(s.length());
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (c == '\\' && i + 1 < s.length()) {
+ sb.append(s.charAt(++i));
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ private static SipCodecException malformed(String message) {
+ return new SipCodecException(
+ SipCodecException.Category.MALFORMED_HEADER, -1, message);
+ }
+}
diff --git a/sip-codec/src/main/java/com/sip/codec/typed/RouteSetParser.java b/sip-codec/src/main/java/com/sip/codec/typed/RouteSetParser.java
new file mode 100644
index 0000000..c77ca2b
--- /dev/null
+++ b/sip-codec/src/main/java/com/sip/codec/typed/RouteSetParser.java
@@ -0,0 +1,39 @@
+package com.sip.codec.typed;
+
+import com.sip.message.header.HeaderName;
+import com.sip.message.header.Headers;
+import com.sip.message.header.RawHeader;
+import com.sip.message.header.typed.NameAddr;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Parses Route (RFC 3261 §20.34) and Record-Route (§20.30) header lists.
+ *
+ * Both headers carry a comma-separated list of {@code name-addr}s.
+ * Multiple header lines are equivalent to a single header line with the
+ * values concatenated by commas, in order.
+ */
+public final class RouteSetParser {
+
+ private RouteSetParser() { }
+
+ /** Parse the Route header set, preserving wire order. */
+ public static List route(Headers headers) {
+ return collect(headers, HeaderName.ROUTE);
+ }
+
+ /** Parse the Record-Route header set, preserving wire order. */
+ public static List recordRoute(Headers headers) {
+ return collect(headers, HeaderName.RECORD_ROUTE);
+ }
+
+ private static List collect(Headers headers, HeaderName name) {
+ List out = new ArrayList<>();
+ for (RawHeader raw : headers.all(name)) {
+ out.addAll(NameAddrParser.parseList(raw.value()));
+ }
+ return out;
+ }
+}
diff --git a/sip-codec/src/main/java/com/sip/codec/typed/ViaParser.java b/sip-codec/src/main/java/com/sip/codec/typed/ViaParser.java
new file mode 100644
index 0000000..ebeba08
--- /dev/null
+++ b/sip-codec/src/main/java/com/sip/codec/typed/ViaParser.java
@@ -0,0 +1,206 @@
+package com.sip.codec.typed;
+
+import com.sip.codec.SipCodecException;
+import com.sip.message.header.HeaderName;
+import com.sip.message.header.Headers;
+import com.sip.message.header.RawHeader;
+import com.sip.message.header.typed.HostPort;
+import com.sip.message.header.typed.ViaHeader;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Parses Via header field values per RFC 3261 §20.42 / §25.
+ *
+ * A single Via header may contain multiple comma-separated
+ * {@code via-parm}s; equivalently, repeated Via headers may appear in the
+ * message. This parser handles both forms transparently.
+ */
+public final class ViaParser {
+
+ private ViaParser() { }
+
+ /** Parses all Via header field values across all Via headers in the message. */
+ public static List parseAll(Headers headers) {
+ List out = new ArrayList<>();
+ for (RawHeader raw : headers.all(HeaderName.VIA)) {
+ for (String value : splitTopLevelCommas(raw.value())) {
+ out.add(parseSingle(value.trim()));
+ }
+ }
+ return out;
+ }
+
+ /** Parses a single {@code via-parm} (no commas). */
+ public static ViaHeader parseSingle(String text) {
+ int sp = indexOfWs(text, 0);
+ if (sp < 0) {
+ throw malformed("Via missing whitespace between sent-protocol and sent-by: '"
+ + text + "'");
+ }
+ String sentProtocol = text.substring(0, sp);
+ String[] protoParts = splitChar(sentProtocol, '/');
+ if (protoParts.length != 3) {
+ throw malformed("Via sent-protocol must have exactly two '/' separators: '"
+ + sentProtocol + "'");
+ }
+ String protocolName = protoParts[0].trim();
+ String protocolVersion = protoParts[1].trim();
+ String transport = protoParts[2].trim();
+ if (protocolName.isEmpty() || protocolVersion.isEmpty() || transport.isEmpty()) {
+ throw malformed("Via sent-protocol has empty component: '" + sentProtocol + "'");
+ }
+
+ int i = sp;
+ while (i < text.length() && isWs(text.charAt(i))) {
+ i++;
+ }
+
+ int semi = indexOf(text, ';', i);
+ String sentByText = (semi < 0 ? text.substring(i) : text.substring(i, semi)).trim();
+ HostPort sentBy = parseSentBy(sentByText);
+
+ Map params = new LinkedHashMap<>();
+ if (semi >= 0) {
+ for (String p : splitParams(text.substring(semi + 1))) {
+ int eq = p.indexOf('=');
+ if (eq < 0) {
+ String name = p.trim();
+ if (name.isEmpty()) {
+ throw malformed(
+ "Via parameter list contains an empty token "
+ + "(extraneous ';' separators)");
+ }
+ params.put(name, "");
+ } else {
+ String name = p.substring(0, eq).trim();
+ String value = p.substring(eq + 1).trim();
+ if (name.isEmpty()) {
+ throw malformed("Via parameter has empty name in '" + p + "'");
+ }
+ if (value.isEmpty()) {
+ throw malformed(
+ "Via parameter has empty value (separator without value): '"
+ + p + "'");
+ }
+ params.put(name, value);
+ }
+ }
+ }
+ return new ViaHeader(protocolName, protocolVersion, transport, sentBy, params);
+ }
+
+ private static HostPort parseSentBy(String text) {
+ if (text.isEmpty()) {
+ throw malformed("Via sent-by is empty");
+ }
+ if (text.startsWith("[")) {
+ int closeBracket = text.indexOf(']');
+ if (closeBracket < 0) {
+ throw malformed("Via sent-by missing closing ']' for IPv6 reference");
+ }
+ String host = text.substring(0, closeBracket + 1);
+ if (closeBracket + 1 == text.length()) {
+ return HostPort.of(host);
+ }
+ if (text.charAt(closeBracket + 1) != ':') {
+ throw malformed("Via sent-by garbage after IPv6 reference: '" + text + "'");
+ }
+ return new HostPort(host, parsePort(text.substring(closeBracket + 2)));
+ }
+ int colon = text.lastIndexOf(':');
+ if (colon < 0) {
+ return HostPort.of(text);
+ }
+ return new HostPort(text.substring(0, colon), parsePort(text.substring(colon + 1)));
+ }
+
+ private static int parsePort(String s) {
+ try {
+ int p = Integer.parseInt(s.trim());
+ if (p < 0 || p > 65535) {
+ throw malformed("Via sent-by port out of range: " + p);
+ }
+ return p;
+ } catch (NumberFormatException e) {
+ throw malformed("Via sent-by port is not numeric: '" + s + "'", e);
+ }
+ }
+
+ /** Splits header values on commas at top level (i.e. not inside quoted strings). */
+ static List splitTopLevelCommas(String s) {
+ List out = new ArrayList<>();
+ boolean inQuotes = false;
+ int start = 0;
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (c == '"' && (i == 0 || s.charAt(i - 1) != '\\')) {
+ inQuotes = !inQuotes;
+ } else if (c == ',' && !inQuotes) {
+ out.add(s.substring(start, i));
+ start = i + 1;
+ }
+ }
+ out.add(s.substring(start));
+ return out;
+ }
+
+ /** Splits a parameter list (text after the first ';') on top-level ';'s. */
+ static List splitParams(String s) {
+ List out = new ArrayList<>();
+ boolean inQuotes = false;
+ int start = 0;
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (c == '"' && (i == 0 || s.charAt(i - 1) != '\\')) {
+ inQuotes = !inQuotes;
+ } else if (c == ';' && !inQuotes) {
+ out.add(s.substring(start, i));
+ start = i + 1;
+ }
+ }
+ out.add(s.substring(start));
+ return out;
+ }
+
+ private static int indexOf(String s, char c, int from) {
+ return s.indexOf(c, from);
+ }
+
+ private static int indexOfWs(String s, int from) {
+ for (int i = from; i < s.length(); i++) {
+ if (isWs(s.charAt(i))) return i;
+ }
+ return -1;
+ }
+
+ private static boolean isWs(char c) {
+ return c == ' ' || c == '\t';
+ }
+
+ private static String[] splitChar(String s, char c) {
+ List out = new ArrayList<>();
+ int start = 0;
+ for (int i = 0; i < s.length(); i++) {
+ if (s.charAt(i) == c) {
+ out.add(s.substring(start, i));
+ start = i + 1;
+ }
+ }
+ out.add(s.substring(start));
+ return out.toArray(new String[0]);
+ }
+
+ private static SipCodecException malformed(String message) {
+ return new SipCodecException(
+ SipCodecException.Category.MALFORMED_HEADER, -1, message);
+ }
+
+ private static SipCodecException malformed(String message, Throwable cause) {
+ return new SipCodecException(
+ SipCodecException.Category.MALFORMED_HEADER, -1, message, cause);
+ }
+}
diff --git a/sip-codec/src/main/java/module-info.java b/sip-codec/src/main/java/module-info.java
index 2a29c90..1ed21ca 100644
--- a/sip-codec/src/main/java/module-info.java
+++ b/sip-codec/src/main/java/module-info.java
@@ -11,4 +11,5 @@
requires transitive com.sip.message;
exports com.sip.codec;
+ exports com.sip.codec.typed;
}
diff --git a/sip-codec/src/test/java/com/sip/codec/typed/CSeqParserTest.java b/sip-codec/src/test/java/com/sip/codec/typed/CSeqParserTest.java
new file mode 100644
index 0000000..6682a09
--- /dev/null
+++ b/sip-codec/src/test/java/com/sip/codec/typed/CSeqParserTest.java
@@ -0,0 +1,57 @@
+package com.sip.codec.typed;
+
+import com.sip.codec.SipCodecException;
+import com.sip.message.SipMethod;
+import com.sip.message.header.typed.CSeqHeader;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class CSeqParserTest {
+
+ @Test
+ void parsesBasicCSeq() {
+ CSeqHeader c = CSeqParser.parse("314159 INVITE");
+ assertThat(c.sequence()).isEqualTo(314159L);
+ assertThat(c.method()).isEqualTo(SipMethod.INVITE);
+ }
+
+ @Test
+ void parsesLeadingZeros() {
+ // RFC 4475 §3.1.1.1 wsinv exercises "cseq: 0009 INVITE"
+ CSeqHeader c = CSeqParser.parse("0009 INVITE");
+ assertThat(c.sequence()).isEqualTo(9L);
+ }
+
+ @Test
+ void rejectsOutOfRangeSequence() {
+ assertThatThrownBy(() -> CSeqParser.parse("9999999999 INVITE"))
+ .isInstanceOf(SipCodecException.class);
+ }
+
+ @Test
+ void rejectsNegativeSequence() {
+ assertThatThrownBy(() -> CSeqParser.parse("-1 INVITE"))
+ .isInstanceOf(SipCodecException.class);
+ }
+
+ @Test
+ void rejectsMissingMethod() {
+ assertThatThrownBy(() -> CSeqParser.parse("42"))
+ .isInstanceOf(SipCodecException.class);
+ }
+
+ @Test
+ void rejectsInvalidMethodToken() {
+ assertThatThrownBy(() -> CSeqParser.parse("42 BAD METHOD"))
+ .isInstanceOf(SipCodecException.class);
+ }
+
+ @Test
+ void asWireRoundtrips() {
+ CSeqHeader c = CSeqParser.parse("123 REGISTER");
+ assertThat(c.asWire()).isEqualTo("123 REGISTER");
+ assertThat(CSeqParser.parse(c.asWire())).isEqualTo(c);
+ }
+}
diff --git a/sip-codec/src/test/java/com/sip/codec/typed/ContactParserTest.java b/sip-codec/src/test/java/com/sip/codec/typed/ContactParserTest.java
new file mode 100644
index 0000000..ce2a2bb
--- /dev/null
+++ b/sip-codec/src/test/java/com/sip/codec/typed/ContactParserTest.java
@@ -0,0 +1,60 @@
+package com.sip.codec.typed;
+
+import com.sip.codec.SipCodecException;
+import com.sip.message.header.Headers;
+import com.sip.message.header.typed.ContactValue;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ContactParserTest {
+
+ @Test
+ void parsesSingleContact() {
+ Headers h = Headers.builder()
+ .add("Contact", ";expires=3600")
+ .build();
+ List list = ContactParser.parseAll(h);
+ assertThat(list).hasSize(1);
+ ContactValue c = list.get(0);
+ assertThat(c.isWildcard()).isFalse();
+ assertThat(c.expires()).contains(3600);
+ }
+
+ @Test
+ void parsesWildcard() {
+ Headers h = Headers.builder()
+ .add("Contact", "*")
+ .build();
+ List list = ContactParser.parseAll(h);
+ assertThat(list).hasSize(1);
+ assertThat(list.get(0).isWildcard()).isTrue();
+ }
+
+ @Test
+ void parsesMultipleContactsInOneHeader() {
+ Headers h = Headers.builder()
+ .add("Contact",
+ ";q=0.5, ;q=1.0")
+ .build();
+ List list = ContactParser.parseAll(h);
+ assertThat(list).hasSize(2);
+ assertThat(list.get(0).qValue()).contains(0.5);
+ assertThat(list.get(1).qValue()).contains(1.0);
+ }
+
+ @Test
+ void rejectsWildcardWithOtherContact() {
+ Headers h = Headers.builder()
+ .add("Contact", "")
+ .add("Contact", "*")
+ .build();
+ assertThatThrownBy(() -> ContactParser.parseAll(h))
+ .isInstanceOf(SipCodecException.class)
+ .satisfies(t -> assertThat(((SipCodecException) t).category())
+ .isEqualTo(SipCodecException.Category.MALFORMED_HEADER));
+ }
+}
diff --git a/sip-codec/src/test/java/com/sip/codec/typed/NameAddrParserTest.java b/sip-codec/src/test/java/com/sip/codec/typed/NameAddrParserTest.java
new file mode 100644
index 0000000..ef9cbe7
--- /dev/null
+++ b/sip-codec/src/test/java/com/sip/codec/typed/NameAddrParserTest.java
@@ -0,0 +1,117 @@
+package com.sip.codec.typed;
+
+import com.sip.codec.SipCodecException;
+import com.sip.message.header.typed.NameAddr;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class NameAddrParserTest {
+
+ @Test
+ void parsesBareAddrSpec() {
+ NameAddr a = NameAddrParser.parseSingle("sip:alice@atlanta.example.com");
+ assertThat(a.displayName()).isEmpty();
+ assertThat(a.uri().asWire()).isEqualTo("sip:alice@atlanta.example.com");
+ assertThat(a.params()).isEmpty();
+ }
+
+ @Test
+ void parsesBareAddrSpecWithTag() {
+ NameAddr a = NameAddrParser.parseSingle("sip:alice@atlanta.example.com;tag=1928301774");
+ assertThat(a.displayName()).isEmpty();
+ assertThat(a.tag()).contains("1928301774");
+ }
+
+ @Test
+ void parsesAngleBracketedAddrSpec() {
+ NameAddr a = NameAddrParser.parseSingle(";tag=a6c85cf");
+ assertThat(a.displayName()).isEmpty();
+ assertThat(a.uri().scheme()).isEqualTo("sip");
+ assertThat(a.tag()).contains("a6c85cf");
+ }
+
+ @Test
+ void parsesTokenDisplayName() {
+ NameAddr a = NameAddrParser.parseSingle("Alice ;tag=1");
+ assertThat(a.displayName()).contains("Alice");
+ assertThat(a.tag()).contains("1");
+ }
+
+ @Test
+ void parsesNoLwsBetweenDisplayNameAndAngle() {
+ // RFC 4475 §3.1.1.6 lwsdisp: "caller;tag=323"
+ NameAddr a = NameAddrParser.parseSingle("caller;tag=323");
+ assertThat(a.displayName()).contains("caller");
+ assertThat(a.tag()).contains("323");
+ }
+
+ @Test
+ void parsesQuotedDisplayName() {
+ NameAddr a = NameAddrParser.parseSingle(
+ "\"J Doe\" ;tag=t1");
+ assertThat(a.displayName()).contains("J Doe");
+ assertThat(a.tag()).contains("t1");
+ }
+
+ @Test
+ void parsesQuotedDisplayNameWithEscapes() {
+ NameAddr a = NameAddrParser.parseSingle(
+ "\"J Rosenberg \\\\\\\"\" ");
+ // Quoted "J Rosenberg \\\"" → unescaped to: J Rosenberg \"
+ assertThat(a.displayName()).contains("J Rosenberg \\\"");
+ }
+
+ @Test
+ void parsesListOfNameAddrs() {
+ List list = NameAddrParser.parseList(
+ ", Bob , \"Carol\" ;tag=t");
+ assertThat(list).hasSize(3);
+ assertThat(list.get(1).displayName()).contains("Bob");
+ assertThat(list.get(2).tag()).contains("t");
+ }
+
+ @Test
+ void rejectsMissingClosingAngle() {
+ assertThatThrownBy(() -> NameAddrParser.parseSingle("Alice assertThat(((SipCodecException) t).category())
+ .isEqualTo(SipCodecException.Category.MALFORMED_HEADER));
+ }
+
+ @Test
+ void rejectsUnterminatedQuotedString() {
+ assertThatThrownBy(() -> NameAddrParser.parseSingle("\"never closed "))
+ .isInstanceOf(SipCodecException.class);
+ }
+
+ @Test
+ void rejectsBadinv01StyleContact() {
+ // RFC 4475 §3.1.2.1 Contact: "\"Joe\" ;;;;"
+ assertThatThrownBy(() -> NameAddrParser.parseSingle(
+ "\"Joe\" ;;;;"))
+ .isInstanceOf(SipCodecException.class)
+ .satisfies(t -> assertThat(((SipCodecException) t).category())
+ .isEqualTo(SipCodecException.Category.MALFORMED_HEADER));
+ }
+
+ @Test
+ void asWireRoundtrips() {
+ String[] cases = {
+ "sip:alice@atlanta",
+ ";tag=t",
+ "Alice ;tag=1",
+ "\"J Doe\" ;tag=u"
+ };
+ for (String in : cases) {
+ NameAddr parsed = NameAddrParser.parseSingle(in);
+ String back = parsed.asWire();
+ assertThat(NameAddrParser.parseSingle(back).asWire())
+ .as("roundtrip: " + in)
+ .isEqualTo(back);
+ }
+ }
+}
diff --git a/sip-codec/src/test/java/com/sip/codec/typed/SimpleHeadersTest.java b/sip-codec/src/test/java/com/sip/codec/typed/SimpleHeadersTest.java
new file mode 100644
index 0000000..2cbd9f2
--- /dev/null
+++ b/sip-codec/src/test/java/com/sip/codec/typed/SimpleHeadersTest.java
@@ -0,0 +1,60 @@
+package com.sip.codec.typed;
+
+import com.sip.codec.SipCodecException;
+import com.sip.message.header.Headers;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class SimpleHeadersTest {
+
+ @Test
+ void parsesMaxForwards() {
+ Headers h = Headers.builder().add("Max-Forwards", "70").build();
+ assertThat(IntegerHeaderParser.maxForwards(h)).contains(70);
+ }
+
+ @Test
+ void rejectsMaxForwardsAboveRange() {
+ Headers h = Headers.builder().add("Max-Forwards", "300").build();
+ assertThatThrownBy(() -> IntegerHeaderParser.maxForwards(h))
+ .isInstanceOf(SipCodecException.class);
+ }
+
+ @Test
+ void rejectsMaxForwardsNegative() {
+ Headers h = Headers.builder().add("Max-Forwards", "-1").build();
+ assertThatThrownBy(() -> IntegerHeaderParser.maxForwards(h))
+ .isInstanceOf(SipCodecException.class);
+ }
+
+ @Test
+ void parsesExpires() {
+ Headers h = Headers.builder().add("Expires", "3600").build();
+ assertThat(IntegerHeaderParser.expires(h)).contains(3600);
+ }
+
+ @Test
+ void parsesContentLength() {
+ Headers h = Headers.builder().add("Content-Length", "162").build();
+ assertThat(IntegerHeaderParser.contentLength(h)).contains(162);
+ }
+
+ @Test
+ void validatesCallId() {
+ Headers h = Headers.builder()
+ .add("Call-ID", "a84b4c76e66710@pc33.atlanta")
+ .build();
+ assertThat(CallIdValidator.get(h)).contains("a84b4c76e66710@pc33.atlanta");
+ }
+
+ @Test
+ void rejectsCallIdWithSpace() {
+ Headers h = Headers.builder()
+ .add("Call-ID", "bad call id")
+ .build();
+ assertThatThrownBy(() -> CallIdValidator.get(h))
+ .isInstanceOf(SipCodecException.class);
+ }
+}
diff --git a/sip-codec/src/test/java/com/sip/codec/typed/ViaParserTest.java b/sip-codec/src/test/java/com/sip/codec/typed/ViaParserTest.java
new file mode 100644
index 0000000..49703f8
--- /dev/null
+++ b/sip-codec/src/test/java/com/sip/codec/typed/ViaParserTest.java
@@ -0,0 +1,114 @@
+package com.sip.codec.typed;
+
+import com.sip.codec.SipCodecException;
+import com.sip.message.header.Headers;
+import com.sip.message.header.typed.ViaHeader;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ViaParserTest {
+
+ @Test
+ void parsesSingleViaHeader() {
+ ViaHeader v = ViaParser.parseSingle(
+ "SIP/2.0/UDP pc33.atlanta.example.com;branch=z9hG4bKnashds10");
+ assertThat(v.protocolName()).isEqualTo("SIP");
+ assertThat(v.protocolVersion()).isEqualTo("2.0");
+ assertThat(v.transport()).isEqualTo("UDP");
+ assertThat(v.sentBy().host()).isEqualTo("pc33.atlanta.example.com");
+ assertThat(v.sentBy().hasPort()).isFalse();
+ assertThat(v.branch()).contains("z9hG4bKnashds10");
+ }
+
+ @Test
+ void parsesIpv6SentBy() {
+ ViaHeader v = ViaParser.parseSingle(
+ "SIP/2.0/UDP [2001:db8::1]:5060;branch=z9hG4bK1");
+ assertThat(v.sentBy().host()).isEqualTo("[2001:db8::1]");
+ assertThat(v.sentBy().port()).isEqualTo(5060);
+ }
+
+ @Test
+ void parsesMultipleViaValuesInSingleHeader() {
+ Headers h = Headers.builder()
+ .add("Via", "SIP/2.0/UDP h1;branch=b1, SIP/2.0/TCP h2;branch=b2")
+ .build();
+ List all = ViaParser.parseAll(h);
+ assertThat(all).hasSize(2);
+ assertThat(all.get(0).transport()).isEqualTo("UDP");
+ assertThat(all.get(0).branch()).contains("b1");
+ assertThat(all.get(1).transport()).isEqualTo("TCP");
+ assertThat(all.get(1).branch()).contains("b2");
+ }
+
+ @Test
+ void parsesAllAcrossMultipleViaHeaders() {
+ Headers h = Headers.builder()
+ .add("Via", "SIP/2.0/UDP h1;branch=b1")
+ .add("Via", "SIP/2.0/TLS h2;branch=b2")
+ .add("Via", "SIP/2.0/SCTP h3;branch=b3")
+ .build();
+ List all = ViaParser.parseAll(h);
+ assertThat(all).hasSize(3);
+ assertThat(all).extracting(ViaHeader::transport)
+ .containsExactly("UDP", "TLS", "SCTP");
+ }
+
+ @Test
+ void parsesReceivedAndRport() {
+ ViaHeader v = ViaParser.parseSingle(
+ "SIP/2.0/UDP host;branch=z9hG4bK1;received=192.0.2.5;rport=5060");
+ assertThat(v.received()).contains("192.0.2.5");
+ assertThat(v.rport()).contains("5060");
+ }
+
+ @Test
+ void parsesValuelessRport() {
+ ViaHeader v = ViaParser.parseSingle("SIP/2.0/UDP host;branch=z;rport");
+ assertThat(v.rport()).contains("");
+ }
+
+ @Test
+ void rejectsBadinv01StyleEmptySeparators() {
+ // RFC 4475 §3.1.2.1 Via: "SIP/2.0/UDP 192.0.2.15;;,;,,"
+ // The trailing `;;,;,,` produces empty parameter slots — must be rejected.
+ assertThatThrownBy(() -> ViaParser.parseAll(Headers.builder()
+ .add("Via", "SIP/2.0/UDP 192.0.2.15;;,;,,")
+ .build()))
+ .isInstanceOf(SipCodecException.class)
+ .satisfies(t -> assertThat(((SipCodecException) t).category())
+ .isEqualTo(SipCodecException.Category.MALFORMED_HEADER));
+ }
+
+ @Test
+ void rejectsMissingTransport() {
+ assertThatThrownBy(() -> ViaParser.parseSingle("SIP/2.0 host"))
+ .isInstanceOf(SipCodecException.class);
+ }
+
+ @Test
+ void rejectsMissingSentBy() {
+ assertThatThrownBy(() -> ViaParser.parseSingle("SIP/2.0/UDP"))
+ .isInstanceOf(SipCodecException.class);
+ }
+
+ @Test
+ void asWireRoundtrips() {
+ String[] cases = {
+ "SIP/2.0/UDP host.example.com;branch=z9hG4bK1",
+ "SIP/2.0/TCP host;branch=b;received=192.0.2.1;rport=5060",
+ "SIP/2.0/UDP [2001:db8::1]:5060;branch=z"
+ };
+ for (String in : cases) {
+ ViaHeader parsed = ViaParser.parseSingle(in);
+ String back = parsed.asWire();
+ assertThat(ViaParser.parseSingle(back).asWire())
+ .as("roundtrip: " + in)
+ .isEqualTo(back);
+ }
+ }
+}
diff --git a/sip-compliance-tests/src/test/java/com/sip/compliance/parser/ParserConformanceTest.java b/sip-compliance-tests/src/test/java/com/sip/compliance/parser/ParserConformanceTest.java
index 828085c..7d87c94 100644
--- a/sip-compliance-tests/src/test/java/com/sip/compliance/parser/ParserConformanceTest.java
+++ b/sip-compliance-tests/src/test/java/com/sip/compliance/parser/ParserConformanceTest.java
@@ -3,6 +3,8 @@
import com.sip.codec.SipCodecException;
import com.sip.codec.SipEncoder;
import com.sip.codec.SipParser;
+import com.sip.codec.typed.ContactParser;
+import com.sip.codec.typed.ViaParser;
import com.sip.compliance.fixture.FixtureExpectation;
import com.sip.compliance.fixture.FixtureRepository;
import com.sip.compliance.fixture.MessageKind;
@@ -59,8 +61,7 @@ Iterable everyAcceptFixtureRoundtripsThroughEncoder() {
/**
* Reject-conformance is scoped to the {@code structural} parser phase.
* Fixtures whose rejection happens later in the pipeline (typed-header
- * parsing, transaction layer, …) live in the repository but are filtered
- * out here and become the gate for whichever phase ships them.
+ * parsing, transaction layer, …) are tested separately below.
*/
@TestFactory
Iterable everyStructuralRejectFixtureIsRejectedWithDeclaredCategory() {
@@ -72,6 +73,24 @@ Iterable everyStructuralRejectFixtureIsRejectedWithDeclaredCategory
.toList();
}
+ /**
+ * Typed-header phase rejections: the structural parser is tolerant
+ * but a typed Via / Contact / From / To parser must reject. We run
+ * each fixture through the structural parser then through the typed
+ * Via and Contact parsers; the test passes when at least one of the
+ * typed parsers throws a {@link SipCodecException} matching the
+ * declared category.
+ */
+ @TestFactory
+ Iterable everyTypedRejectFixtureIsRejectedByATypedHeaderParser() {
+ return FIXTURES.stream()
+ .filter(fx -> fx.expectation() instanceof FixtureExpectation.Reject r
+ && "typed-header".equals(r.phase()))
+ .map(fx -> dynamicTest("typed-reject: " + fx.id(),
+ () -> assertTypedHeaderParserRejects(fx)))
+ .toList();
+ }
+
private static void assertParserAccepts(TortureFixture fx) {
FixtureExpectation.Accept e = (FixtureExpectation.Accept) fx.expectation();
SipMessage msg = SipParser.parse(fx.bytes());
@@ -152,4 +171,22 @@ private static void assertParserRejects(TortureFixture fx) {
.as(fx.id() + ": category mismatch")
.isEqualTo(r.category());
}
+
+ private static void assertTypedHeaderParserRejects(TortureFixture fx) {
+ FixtureExpectation.Reject r = (FixtureExpectation.Reject) fx.expectation();
+ SipMessage msg = SipParser.parse(fx.bytes());
+
+ SipCodecException viaError = catchThrowableOfType(
+ SipCodecException.class, () -> ViaParser.parseAll(msg.headers()));
+ SipCodecException contactError = catchThrowableOfType(
+ SipCodecException.class, () -> ContactParser.parseAll(msg.headers()));
+
+ SipCodecException either = viaError != null ? viaError : contactError;
+ assertThat(either)
+ .as(fx.id() + ": at least one typed-header parser must reject")
+ .isNotNull();
+ assertThat(either.category())
+ .as(fx.id() + ": typed-header rejection category mismatch")
+ .isEqualTo(r.category());
+ }
}
diff --git a/sip-message/src/main/java/com/sip/message/header/typed/CSeqHeader.java b/sip-message/src/main/java/com/sip/message/header/typed/CSeqHeader.java
new file mode 100644
index 0000000..e04eafa
--- /dev/null
+++ b/sip-message/src/main/java/com/sip/message/header/typed/CSeqHeader.java
@@ -0,0 +1,33 @@
+package com.sip.message.header.typed;
+
+import com.sip.message.SipMethod;
+
+import java.util.Objects;
+
+/**
+ * Typed view of a CSeq header (RFC 3261 §20.16, §25 ABNF).
+ *
+ *
+ * CSeq = "CSeq" HCOLON 1*DIGIT LWS Method
+ *
+ *
+ * @param sequence non-negative sequence number; must fit in 32 bits (per RFC)
+ * @param method the request method this CSeq tracks
+ */
+public record CSeqHeader(long sequence, SipMethod method) {
+
+ /** RFC 3261 §8.1.1.5: sequence numbers MUST be less than 2^31. */
+ public static final long MAX_SEQUENCE = (1L << 31) - 1;
+
+ public CSeqHeader {
+ Objects.requireNonNull(method, "method");
+ if (sequence < 0 || sequence > MAX_SEQUENCE) {
+ throw new IllegalArgumentException(
+ "CSeq sequence out of range [0," + MAX_SEQUENCE + "]: " + sequence);
+ }
+ }
+
+ public String asWire() {
+ return sequence + " " + method.name();
+ }
+}
diff --git a/sip-message/src/main/java/com/sip/message/header/typed/ContactValue.java b/sip-message/src/main/java/com/sip/message/header/typed/ContactValue.java
new file mode 100644
index 0000000..1effe96
--- /dev/null
+++ b/sip-message/src/main/java/com/sip/message/header/typed/ContactValue.java
@@ -0,0 +1,73 @@
+package com.sip.message.header.typed;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Contact header field value (RFC 3261 §20.10).
+ *
+ * A Contact value is either:
+ *
+ * - A {@link #wildcard() wildcard} ("*") — only valid in a REGISTER
+ * intended to remove all bindings;
+ * - A {@code name-addr} with optional parameters
+ * ({@code expires}, {@code q}, custom).
+ *
+ */
+public final class ContactValue {
+
+ private static final ContactValue WILDCARD = new ContactValue(null);
+
+ private final NameAddr nameAddr;
+
+ private ContactValue(NameAddr nameAddr) {
+ this.nameAddr = nameAddr;
+ }
+
+ public static ContactValue wildcard() {
+ return WILDCARD;
+ }
+
+ public static ContactValue of(NameAddr nameAddr) {
+ return new ContactValue(Objects.requireNonNull(nameAddr, "nameAddr"));
+ }
+
+ public boolean isWildcard() {
+ return nameAddr == null;
+ }
+
+ public Optional nameAddr() {
+ return Optional.ofNullable(nameAddr);
+ }
+
+ /** RFC 3261 §20.10 — {@code expires} parameter (seconds), if present. */
+ public Optional expires() {
+ if (nameAddr == null) return Optional.empty();
+ return nameAddr.param("expires").map(Integer::parseInt);
+ }
+
+ /** RFC 3261 §20.10 — {@code q} parameter (preference 0..1), if present. */
+ public Optional qValue() {
+ if (nameAddr == null) return Optional.empty();
+ return nameAddr.param("q").map(Double::parseDouble);
+ }
+
+ public String asWire() {
+ return isWildcard() ? "*" : nameAddr.asWire();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ return obj instanceof ContactValue other && Objects.equals(nameAddr, other.nameAddr);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(nameAddr);
+ }
+
+ @Override
+ public String toString() {
+ return asWire();
+ }
+}
diff --git a/sip-message/src/main/java/com/sip/message/header/typed/HostPort.java b/sip-message/src/main/java/com/sip/message/header/typed/HostPort.java
new file mode 100644
index 0000000..cb7757b
--- /dev/null
+++ b/sip-message/src/main/java/com/sip/message/header/typed/HostPort.java
@@ -0,0 +1,41 @@
+package com.sip.message.header.typed;
+
+import java.util.Objects;
+
+/**
+ * Host + optional port pair used by Via {@code sent-by} and Contact host
+ * components (RFC 3261 §25 {@code host:port} ABNF).
+ *
+ * @param host hostname, IPv4 address, or bracketed IPv6 reference
+ * ({@code [2001:db8::1]})
+ * @param port {@code -1} when the port is not declared
+ */
+public record HostPort(String host, int port) {
+
+ public HostPort {
+ Objects.requireNonNull(host, "host");
+ if (host.isEmpty()) {
+ throw new IllegalArgumentException("host must not be empty");
+ }
+ if (port < -1 || port > 65535) {
+ throw new IllegalArgumentException("invalid port: " + port);
+ }
+ }
+
+ public static HostPort of(String host) {
+ return new HostPort(host, -1);
+ }
+
+ public static HostPort of(String host, int port) {
+ return new HostPort(host, port);
+ }
+
+ public boolean hasPort() {
+ return port >= 0;
+ }
+
+ /** Canonical wire form: {@code host} or {@code host:port}. */
+ public String asWire() {
+ return port >= 0 ? host + ":" + port : host;
+ }
+}
diff --git a/sip-message/src/main/java/com/sip/message/header/typed/NameAddr.java b/sip-message/src/main/java/com/sip/message/header/typed/NameAddr.java
new file mode 100644
index 0000000..7ae9715
--- /dev/null
+++ b/sip-message/src/main/java/com/sip/message/header/typed/NameAddr.java
@@ -0,0 +1,116 @@
+package com.sip.message.header.typed;
+
+import com.sip.message.uri.Uri;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * RFC 3261 §25 {@code name-addr} construction used by From, To, Contact,
+ * Route and Record-Route header field values.
+ *
+ *
+ * name-addr = [ display-name ] LAQUOT addr-spec RAQUOT
+ * addr-spec = SIP-URI / SIPS-URI / absoluteURI
+ * display-name = *(token LWS) / quoted-string
+ *
+ *
+ * This record additionally carries header parameters (everything that
+ * follows the {@code } or the {@code addr-spec} itself, separated
+ * by semicolons). Examples include {@code ;tag=xyz} on From / To and
+ * {@code ;q=0.5;expires=3600} on Contact.
+ *
+ * @param displayName optional human-readable label (already unquoted)
+ * @param uri the contained URI (already parsed)
+ * @param params header parameters in declaration order
+ */
+public record NameAddr(
+ Optional displayName,
+ Uri uri,
+ Map params) {
+
+ public NameAddr {
+ Objects.requireNonNull(displayName, "displayName");
+ Objects.requireNonNull(uri, "uri");
+ Objects.requireNonNull(params, "params");
+ params = Collections.unmodifiableMap(new LinkedHashMap<>(params));
+ }
+
+ public Optional tag() {
+ return Optional.ofNullable(params.get("tag"));
+ }
+
+ public Optional param(String name) {
+ return Optional.ofNullable(params.get(name));
+ }
+
+ /** Canonical wire form matching RFC 3261 §25. */
+ public String asWire() {
+ StringBuilder sb = new StringBuilder(64);
+ displayName.ifPresent(d -> sb.append(quoteIfNeeded(d)).append(' '));
+ sb.append('<').append(uri.asWire()).append('>');
+ for (var entry : params.entrySet()) {
+ sb.append(';').append(entry.getKey());
+ if (!entry.getValue().isEmpty()) {
+ sb.append('=').append(entry.getValue());
+ }
+ }
+ return sb.toString();
+ }
+
+ private static String quoteIfNeeded(String displayName) {
+ if (displayName.isEmpty()) {
+ return "\"\"";
+ }
+ boolean safe = true;
+ for (int i = 0; i < displayName.length(); i++) {
+ char c = displayName.charAt(i);
+ if (c == ' ' || c == '\t' || c == ',' || c == ';' || c == '<' || c == '>'
+ || c == '"' || c == '\\' || (c & 0x80) != 0) {
+ safe = false;
+ break;
+ }
+ }
+ if (safe) {
+ return displayName;
+ }
+ StringBuilder sb = new StringBuilder(displayName.length() + 2);
+ sb.append('"');
+ for (int i = 0; i < displayName.length(); i++) {
+ char c = displayName.charAt(i);
+ if (c == '"' || c == '\\') {
+ sb.append('\\');
+ }
+ sb.append(c);
+ }
+ sb.append('"');
+ return sb.toString();
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private String displayName;
+ private Uri uri;
+ private final Map params = new LinkedHashMap<>();
+
+ Builder() { }
+
+ public Builder displayName(String d) { this.displayName = d; return this; }
+ public Builder uri(Uri u) { this.uri = u; return this; }
+ public Builder param(String name, String value) {
+ params.put(name, value == null ? "" : value);
+ return this;
+ }
+ public Builder tag(String t) { return param("tag", t); }
+
+ public NameAddr build() {
+ return new NameAddr(Optional.ofNullable(displayName), uri, params);
+ }
+ }
+}
diff --git a/sip-message/src/main/java/com/sip/message/header/typed/ViaHeader.java b/sip-message/src/main/java/com/sip/message/header/typed/ViaHeader.java
new file mode 100644
index 0000000..afce964
--- /dev/null
+++ b/sip-message/src/main/java/com/sip/message/header/typed/ViaHeader.java
@@ -0,0 +1,120 @@
+package com.sip.message.header.typed;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Typed view of a single Via header field value (RFC 3261 §20.42, §25 ABNF).
+ *
+ *
+ * Via = ( "Via" / "v" ) HCOLON via-parm *(COMMA via-parm)
+ * via-parm = sent-protocol LWS sent-by *( SEMI via-params )
+ * sent-protocol = protocol-name SLASH protocol-version
+ * SLASH transport
+ * sent-by = host [ COLON port ]
+ * via-params = via-ttl / via-maddr / via-received
+ * / via-branch / via-extension
+ *
+ *
+ * A single Via header field MAY carry multiple {@code via-parm} values
+ * separated by commas; each is represented by one {@link ViaHeader}
+ * instance.
+ *
+ * @param protocolName typically {@code "SIP"}
+ * @param protocolVersion typically {@code "2.0"}
+ * @param transport typically {@code "UDP"} / {@code "TCP"} / {@code "TLS"}
+ * / {@code "SCTP"} / {@code "WS"} / {@code "WSS"}
+ * — uppercase by convention
+ * @param sentBy host[:port] of the originator
+ * @param params via-params (branch, received, rport, ttl, maddr, …)
+ * preserving insertion order
+ */
+public record ViaHeader(
+ String protocolName,
+ String protocolVersion,
+ String transport,
+ HostPort sentBy,
+ Map params) {
+
+ public ViaHeader {
+ Objects.requireNonNull(protocolName, "protocolName");
+ Objects.requireNonNull(protocolVersion, "protocolVersion");
+ Objects.requireNonNull(transport, "transport");
+ Objects.requireNonNull(sentBy, "sentBy");
+ Objects.requireNonNull(params, "params");
+ params = Collections.unmodifiableMap(new LinkedHashMap<>(params));
+ }
+
+ /** RFC 3261 §17 — the transaction-identifying parameter. */
+ public Optional branch() {
+ return Optional.ofNullable(params.get("branch"));
+ }
+
+ /** RFC 3581 — {@code received} parameter (set by next-hop server). */
+ public Optional received() {
+ return Optional.ofNullable(params.get("received"));
+ }
+
+ /** RFC 3581 — {@code rport} parameter (present, present=value, or absent). */
+ public Optional rport() {
+ return Optional.ofNullable(params.get("rport"));
+ }
+
+ public Optional ttl() {
+ return Optional.ofNullable(params.get("ttl"));
+ }
+
+ public Optional maddr() {
+ return Optional.ofNullable(params.get("maddr"));
+ }
+
+ /** Canonical wire form (matches RFC 3261 §25 ABNF). */
+ public String asWire() {
+ StringBuilder sb = new StringBuilder(64);
+ sb.append(protocolName).append('/').append(protocolVersion).append('/')
+ .append(transport).append(' ').append(sentBy.asWire());
+ for (var entry : params.entrySet()) {
+ sb.append(';').append(entry.getKey());
+ if (!entry.getValue().isEmpty()) {
+ sb.append('=').append(entry.getValue());
+ }
+ }
+ return sb.toString();
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private String protocolName = "SIP";
+ private String protocolVersion = "2.0";
+ private String transport;
+ private HostPort sentBy;
+ private final Map params = new LinkedHashMap<>();
+
+ Builder() { }
+
+ public Builder protocolName(String v) { this.protocolName = v; return this; }
+ public Builder protocolVersion(String v) { this.protocolVersion = v; return this; }
+ public Builder transport(String v) { this.transport = v; return this; }
+ public Builder sentBy(HostPort v) { this.sentBy = v; return this; }
+ public Builder sentBy(String host, int port) {
+ return sentBy(new HostPort(host, port));
+ }
+ public Builder param(String name, String value) {
+ params.put(name, value == null ? "" : value);
+ return this;
+ }
+ public Builder branch(String b) { return param("branch", b); }
+ public Builder received(String r) { return param("received", r); }
+ public Builder rport(String r) { return param("rport", r == null ? "" : r); }
+
+ public ViaHeader build() {
+ return new ViaHeader(protocolName, protocolVersion, transport, sentBy, params);
+ }
+ }
+}
diff --git a/sip-message/src/main/java/com/sip/message/header/typed/package-info.java b/sip-message/src/main/java/com/sip/message/header/typed/package-info.java
new file mode 100644
index 0000000..b9ab663
--- /dev/null
+++ b/sip-message/src/main/java/com/sip/message/header/typed/package-info.java
@@ -0,0 +1,11 @@
+/**
+ * Typed views of common SIP header field values.
+ *
+ * These types are immutable records / value objects that wrap parsed
+ * structures (host:port, name-addr, parameters). They live in
+ * {@code sip-message} so that callers can construct outbound messages
+ * without depending on {@code sip-codec}. Actual parsing from
+ * {@link com.sip.message.header.RawHeader RawHeader} text values lives in
+ * {@code com.sip.codec.typed} inside the {@code sip-codec} module.
+ */
+package com.sip.message.header.typed;
diff --git a/sip-message/src/main/java/com/sip/message/uri/SipUri.java b/sip-message/src/main/java/com/sip/message/uri/SipUri.java
index 482509a..2b6201b 100644
--- a/sip-message/src/main/java/com/sip/message/uri/SipUri.java
+++ b/sip-message/src/main/java/com/sip/message/uri/SipUri.java
@@ -1,5 +1,6 @@
package com.sip.message.uri;
+import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
@@ -41,8 +42,8 @@ public record SipUri(
if (port < -1 || port > 65535) {
throw new IllegalArgumentException("invalid port: " + port);
}
- params = Map.copyOf(params);
- headers = Map.copyOf(headers);
+ params = Collections.unmodifiableMap(new LinkedHashMap<>(params));
+ headers = Collections.unmodifiableMap(new LinkedHashMap<>(headers));
}
@Override
diff --git a/sip-message/src/main/java/module-info.java b/sip-message/src/main/java/module-info.java
index bc93f69..f21e0d5 100644
--- a/sip-message/src/main/java/module-info.java
+++ b/sip-message/src/main/java/module-info.java
@@ -7,5 +7,6 @@
module com.sip.message {
exports com.sip.message;
exports com.sip.message.header;
+ exports com.sip.message.header.typed;
exports com.sip.message.uri;
}