Repository: xconnio/wampproto-python

Issue:
Generic message validator
a single class that can be used to validate any wamp message

Discussion:

Patch:
diff --git a/wampproto/messages/call.py b/wampproto/messages/call.py
index e7e9975..cf1cfd7 100644
--- a/wampproto/messages/call.py
+++ b/wampproto/messages/call.py
@@ -2,14 +2,28 @@ from __future__ import annotations
 
 from typing import Any
 
+from wampproto.messages import util
 from wampproto.messages.message import Message
-from wampproto.messages import util, exceptions
+from wampproto.messages.validation_spec import ValidationSpec
 
 
 class Call(Message):
     TEXT = "CALL"
     TYPE = 48
 
+    # index number mapped to validation interface
+    VALIDATION_SPEC = ValidationSpec(
+        min_length=4,
+        max_length=6,
+        spec={
+            1: util.validate_request_id,
+            2: util.validate_options,
+            3: util.validate_uri,
+            4: util.validate_args,
+            5: util.validate_kwargs,
+        },
+    )
+
     def __init__(
         self,
         request_id: int,
@@ -27,25 +41,8 @@ class Call(Message):
 
     @staticmethod
     def parse(msg: list[Any]) -> Call:
-        util.sanity_check(msg, 4, 6, Call.TYPE, Call.TEXT)
-
-        request_id = util.validate_session_id_or_raise(msg[1], Call.TEXT, "request ID")
-        options = util.validate_details_or_raise(msg[2], Call.TEXT, "options")
-        uri = util.validate_uri_or_raise(msg[3], Call.TEXT)
-
-        args = None
-        if len(msg) > 4:
-            args = msg[4]
-            if not isinstance(args, list):
-                raise exceptions.InvalidTypeError(list, type(msg[4]), "args", Call.TEXT)
-
-        kwargs = None
-        if len(msg) > 5:
-            kwargs = msg[5]
-            if not isinstance(kwargs, dict):
-                raise exceptions.InvalidTypeError(dict, type(msg[5]), "kwargs", Call.TEXT)
-
-        return Call(request_id, uri, args, kwargs, options)
+        f = util.validate_message(msg, Call.TYPE, Call.TEXT, Call.VALIDATION_SPEC)
+        return Call(f.request_id, f.uri, f.args, f.kwargs, f.options)
 
     def marshal(self) -> list[Any]:
         message = [Call.TYPE, self.request_id, self.options, self.uri]
diff --git a/wampproto/messages/util.py b/wampproto/messages/util.py
index 4d60523..2069f0d 100644
--- a/wampproto/messages/util.py
+++ b/wampproto/messages/util.py
@@ -2,6 +2,7 @@ from enum import Enum
 from typing import Any
 
 from wampproto.messages import exceptions
+from wampproto.messages.validation_spec import ValidationSpec
 
 
 class AllowedRoles(str, Enum):
@@ -75,3 +76,84 @@ def sanity_check(wamp_message: list[Any], min_length: int, max_length: int, expe
     message_id = wamp_message[0]
     if message_id != expected_id:
         raise ValueError(f"invalid message id {message_id} for {name}, expected {expected_id}")
+
+
+class Fields:
+    def __init__(self):
+        super().__init__()
+        self.request_id: int | None = None
+        self.uri: str | None = None
+        self.args: list[Any] | None = None
+        self.kwargs: dict[str, Any] | None = None
+
+        self.extra: dict[str, Any] | None = None
+        self.options: dict[str, Any] | None = None
+        self.details: dict[str, Any] | None = None
+
+        self.subscription_id: int | None = None
+        self.publication_id: int | None = None
+
+        self.registration_id: int | None = None
+
+
+def validate_request_id(msg: list[Any], index, fields: Fields):
+    assert isinstance(msg[index], int)
+    validate_session_id_or_raise(msg[index], "CALL", "request ID")
+    fields.request_id = msg[index]
+
+
+def validate_uri(msg: list[Any], index, fields: Fields):
+    assert isinstance(msg[index], str)
+    fields.uri = msg[index]
+
+
+def validate_args(msg: list[Any], index, fields: Fields):
+    if len(msg) > index:
+        assert isinstance(msg[index], list)
+        fields.args = msg[index]
+
+
+def validate_kwargs(msg: list[Any], index, fields: Fields):
+    if len(msg) > index:
+        assert isinstance(msg[index], dict)
+        fields.kwargs = msg[index]
+
+
+def validate_extra(msg: list[Any], index, fields: Fields):
+    assert isinstance(msg[index], dict)
+    fields.extra = msg[index]
+
+
+def validate_options(msg: list[Any], index, fields: Fields):
+    assert isinstance(msg[index], dict)
+    fields.options = msg[index]
+
+
+def validate_details(msg: list[Any], index, fields: Fields):
+    assert isinstance(msg[index], dict)
+    fields.details = msg[index]
+
+
+def validate_subscription_id(msg: list[Any], index, fields: Fields):
+    assert isinstance(msg[index], int)
+    fields.subscription_id = msg[index]
+
+
+def validate_publication_id(msg: list[Any], index, fields: Fields):
+    assert isinstance(msg[index], int)
+    fields.publication_id = msg[index]
+
+
+def validate_registration_id(msg: list[Any], index, fields: Fields):
+    assert isinstance(msg[index], int)
+    fields.registration_id = msg[index]
+
+
+def validate_message(msg: list[Any], type_: int, name: str, val_spec: ValidationSpec) -> Fields:
+    sanity_check(msg, val_spec.min_length, val_spec.max_length, type_, name)
+
+    f = Fields()
+    for idx, func in val_spec.spec.items():
+        func(msg, idx, f)
+
+    return f
diff --git a/wampproto/messages/validation_spec.py b/wampproto/messages/validation_spec.py
new file mode 100644
index 0000000..b9723e2
--- /dev/null
+++ b/wampproto/messages/validation_spec.py
@@ -0,0 +1,8 @@
+from typing import Callable
+
+
+class ValidationSpec:
+    def __init__(self, min_length: int, max_length: int, spec: dict[int, Callable]) -> None:
+        self.min_length = min_length
+        self.max_length = max_length
+        self.spec = spec


Test patch:
diff --git a/tests/messages/test_call.py b/tests/messages/test_call.py
index fe41164..1a3097c 100644
--- a/tests/messages/test_call.py
+++ b/tests/messages/test_call.py
@@ -55,51 +55,33 @@ def test_parse_with_out_of_range_request_value():
 
 def test_parse_with_invalid_options_type():
     message = [messages.Call.TYPE, 367, "options", "io.xconn.ping"]
-    with pytest.raises(exceptions.InvalidDetailsError) as exc_info:
+    with pytest.raises(AssertionError):
         messages.Call.parse(message)
 
-    assert str(exc_info.value) == f"options must be of type dictionary for {messages.Call.TEXT}"
-
-
-def test_parse_with_invalid_options_dict_key():
-    message = [messages.Call.TYPE, 367, {2: "v"}, "io.xconn.ping"]
-    with pytest.raises(exceptions.InvalidDetailsError) as exc_info:
-        messages.Call.parse(message)
-
-    assert str(exc_info.value) == f"invalid type for key '2' in extra details for {messages.Call.TEXT}"
-
 
 def test_parse_with_uri_none():
     message = [messages.Call.TYPE, 367, {}, None]
-    with pytest.raises(exceptions.InvalidUriError) as exc_info:
+    with pytest.raises(AssertionError):
         messages.Call.parse(message)
 
-    assert str(exc_info.value) == f"uri cannot be null for {messages.Call.TEXT}"
-
 
 def test_parse_with_invalid_uri_type():
     message = [messages.Call.TYPE, 367, {}, {"uri": "io.xconn.ping"}]
-    with pytest.raises(exceptions.InvalidUriError) as exc_info:
+    with pytest.raises(AssertionError):
         messages.Call.parse(message)
 
-    assert str(exc_info.value) == f"uri must be of type string for {messages.Call.TEXT}"
-
 
 def test_parse_with_invalid_args_type():
     message = [messages.Call.TYPE, 367, {}, "io.xconn.ping", "args"]
-    with pytest.raises(exceptions.InvalidTypeError) as exc_info:
+    with pytest.raises(AssertionError):
         messages.Call.parse(message)
 
-    assert str(exc_info.value) == f"invalid type: expected type 'list', got 'str' for args in '{messages.Call.TEXT}'"
-
 
 def test_parse_with_invalid_kwargs_type():
     message = [messages.Call.TYPE, 367, {}, "io.xconn.ping", [], ["kwargs"]]
-    with pytest.raises(exceptions.InvalidTypeError) as exc_info:
+    with pytest.raises(AssertionError):
         messages.Call.parse(message)
 
-    assert str(exc_info.value) == f"invalid type: expected type 'dict', got 'list' for kwargs in '{messages.Call.TEXT}'"
-
 
 def test_parse_correctly():
     uri = "io.xconn.ping"