Skip to content

Commit 58f6a75

Browse files
committed
feat: add XML RPC (de-)serialization
1 parent ae9710b commit 58f6a75

File tree

4 files changed

+517
-1
lines changed

4 files changed

+517
-1
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "sipgate_e2e_test_utils"
7-
version = "0.0.1"
7+
version = "0.1.0"
88
authors = []
99
description = "A library for common utils used in E2E testing (XML-, JSON-RPC, awaiting assertions, JobD helpers)"
1010
readme = "README.md"

sipgate_e2e_test_utils/xml_rpc.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import base64
2+
from dataclasses import dataclass, field
3+
from typing import Any, Callable
4+
from xml.etree import ElementTree
5+
from xml.etree.ElementTree import Element
6+
7+
8+
@dataclass
9+
class XmlRpcRequest:
10+
"""
11+
types not implemented (as not widely used for requests):
12+
- double
13+
- datetime.iso8601
14+
"""
15+
16+
method_name: str
17+
members: dict[str, Any] = field(default_factory=dict)
18+
19+
def __repr__(self) -> str:
20+
return f"<{self.__class__.__name__} methodName='{self.method_name}' members={self.members}>"
21+
22+
@staticmethod
23+
def parse(body: str | bytes) -> 'XmlRpcRequest':
24+
return _parse_xml_rpc_request(body)
25+
26+
def serialize(self) -> str:
27+
return _serialize_xml_rpc_request(self)
28+
29+
30+
@dataclass
31+
class XmlRpcResponse:
32+
"""
33+
types not implemented (as not widely used for responses):
34+
- double
35+
- datetime.iso8601
36+
"""
37+
fault_code: int
38+
fault_string: str
39+
members: dict[str, Any]
40+
41+
def __repr__(self) -> str:
42+
return f"<{self.__class__.__name__} faultCode={self.fault_code} faultString={self.fault_string} members={self.members}>"
43+
44+
@staticmethod
45+
def parse(body: str | bytes) -> 'XmlRpcResponse':
46+
return _parse_xml_rpc_response(body)
47+
48+
49+
def _parse_xml_rpc_request(body: str | bytes) -> XmlRpcRequest:
50+
root = ElementTree.fromstring(body)
51+
if root.tag != 'methodCall':
52+
raise ValueError("Expecting root tag to be '<methodCall>'")
53+
54+
method_name = root.find('methodName')
55+
if method_name is None or method_name.text is None:
56+
raise ValueError("Expected to find non-empty '<methodName>'")
57+
58+
members = {}
59+
for member_node in root.findall('params/param/value/struct/member'):
60+
key, val = __parse_member(member_node)
61+
members[key] = val
62+
63+
return XmlRpcRequest(method_name.text, members)
64+
65+
66+
def _serialize_xml_rpc_request(request: XmlRpcRequest) -> str:
67+
root_param = '' if request.members == {} else f'<param><value>{__serialize_struct(request.members)}</value></param>'
68+
69+
# TODO: omit whitespace?
70+
return f"""<?xml version="1.0"?>
71+
<methodCall>
72+
<methodName>{request.method_name}</methodName>
73+
<params>{root_param}</params>
74+
</methodCall>"""
75+
76+
77+
def _parse_xml_rpc_response(body: str | bytes) -> XmlRpcResponse:
78+
root = ElementTree.fromstring(body)
79+
if root.tag != 'methodResponse':
80+
raise ValueError("Expecting root tag to be '<methodResponse>'")
81+
82+
value = root.find('params/param/value/struct')
83+
if value is None:
84+
value = root.find('fault/value/struct')
85+
86+
if value is None:
87+
raise ValueError("Expecting to find a value")
88+
89+
members = __parse_struct(value)
90+
return XmlRpcResponse(int(members.pop('faultCode')), str(members.pop('faultString')), members)
91+
92+
93+
def __parse_member(node: Element) -> tuple[str, Any]:
94+
assert node.tag == 'member', f"expected 'member', but got {node.tag=}"
95+
96+
name_node = node.find('name')
97+
if name_node is None or name_node.text is None:
98+
raise ValueError("Expected to find non-empty '<member><name>'")
99+
100+
value_node = node.find('value')
101+
if value_node is None:
102+
raise ValueError("Expected to find '<member><value>'")
103+
104+
return name_node.text, __parse_value(value_node)
105+
106+
107+
def __parse_value(node: Element) -> Any:
108+
assert node.tag == 'value', f"expected 'value', but got {node.tag=}"
109+
110+
typed_value = node.find('*')
111+
if typed_value is None:
112+
# it is allowed to omit the <string></string> type
113+
return '' if node.text is None else node.text
114+
115+
val_parsers: dict[str, Callable[[Element], Any]] = {
116+
'int': lambda n: int('' if n.text is None else n.text),
117+
'i4': lambda n: int('' if n.text is None else n.text),
118+
'boolean': lambda n: __parse_boolean(n.text),
119+
'string': lambda n: '' if n.text is None else n.text,
120+
'struct': lambda n: __parse_struct(n),
121+
'array': lambda n: __parse_array(n),
122+
'base64': lambda n: b'' if n.text is None else base64.b64decode(n.text),
123+
}
124+
125+
try:
126+
parser = val_parsers[typed_value.tag]
127+
except KeyError:
128+
raise NotImplementedError(f"Unsupported type '<{typed_value.tag}>'")
129+
130+
try:
131+
return parser(typed_value)
132+
except Exception:
133+
raise ValueError(f"Invalid value '{typed_value.text}' for <{typed_value.tag}>")
134+
135+
136+
def __parse_boolean(text: str | None) -> bool:
137+
if text == '0':
138+
return False
139+
140+
if text == '1':
141+
return True
142+
143+
raise ValueError(f"expected '{text}' to be '0' or '1'")
144+
145+
146+
def __parse_array(node: Element) -> list[Any]:
147+
assert node.tag == 'array', f"expected 'array', but got {node.tag=}"
148+
array_values = node.findall('data/value')
149+
150+
return [__parse_value(struct_node) for struct_node in array_values]
151+
152+
153+
def __parse_struct(node: Element) -> dict[str, Any]:
154+
assert node.tag == 'struct', f"expected 'struct', but got {node.tag=}"
155+
member_nodes = node.findall('member')
156+
157+
return {k: v for k, v in map(__parse_member, member_nodes)}
158+
159+
160+
def __serialize_struct(struct: dict[str, Any]) -> str:
161+
members = [__serialize_member(key, struct[key]) for key in struct]
162+
163+
return f'<struct>{"\n".join(members)}</struct>'
164+
165+
166+
def __serialize_array(array: list[Any]) -> str:
167+
values = [__serialize_value(val) for val in array]
168+
169+
return f'<array><data>{"\n".join(values)}</data></array>'
170+
171+
172+
def __serialize_member(name: str, value: Any) -> str:
173+
if value is None:
174+
return '' # TODO: test
175+
176+
return f"<member><name>{name}</name>{__serialize_value(value)}</member>"
177+
178+
179+
def __serialize_value(value: Any) -> str:
180+
val_mappers: dict[type, Callable[[Any], str]] = {
181+
int: lambda val: f'<i4>{val}</i4>',
182+
str: lambda val: f'<string>{val}</string>',
183+
bool: lambda val: f'<boolean>{val}</boolean>',
184+
dict: lambda val: __serialize_struct(value),
185+
list: lambda val: __serialize_array(value)
186+
}
187+
188+
try:
189+
val_mapper = val_mappers[type(value)]
190+
except KeyError:
191+
raise NotImplementedError(f'unsupported data type ({type(value)}) when serializing {value}')
192+
193+
return f"<value>{val_mapper(value)}</value>"

tests/test_xml_rpc_request.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
from unittest import TestCase
2+
from xml.etree.ElementTree import ParseError
3+
4+
from sipgate_e2e_test_utils.xml_rpc import XmlRpcRequest
5+
6+
7+
class TestSipgateXmlRpcRequest(TestCase):
8+
def test_empty_body(self) -> None:
9+
with self.assertRaises(ParseError):
10+
XmlRpcRequest.parse('')
11+
12+
def test_non_xml_body(self) -> None:
13+
with self.assertRaises(ParseError):
14+
XmlRpcRequest.parse("{'key': 'value'}")
15+
16+
def test_response_is_not_parsed_as_request(self) -> None:
17+
body = """<?xml version="1.0" encoding="UTF-8"?>
18+
<methodResponse>
19+
<params><param><value>
20+
<struct>
21+
<member><name>faultString</name><value><string>OK</string></value></member>
22+
<member><name>faultCode</name><value><i4>200</i4></value></member>
23+
</struct>
24+
</value></param></params>
25+
</methodResponse>"""
26+
27+
with self.assertRaises(ValueError):
28+
XmlRpcRequest.parse(body)
29+
30+
def test_method_name(self) -> None:
31+
body = """<?xml version="1.0"?>
32+
<methodCall>
33+
<methodName>a_method_name</methodName>
34+
<params><param><value></value></param></params>
35+
</methodCall>"""
36+
37+
parsed = XmlRpcRequest.parse(body)
38+
self.assertEqual('a_method_name', parsed.method_name)
39+
40+
def test_valid_members(self) -> None:
41+
body = """<?xml version="1.0"?>
42+
<methodCall>
43+
<methodName>a_method_name</methodName>
44+
<params><param><value>
45+
<struct>
46+
<member><name>TNB</name><value><string>D222</string></value></member>
47+
<member><name>port_date</name><value><string>2025-01-01</string></value></member>
48+
<member><name>emptyString</name><value><string></string></value></member>
49+
<member><name>stringWithoutType</name><value>thisIsAlsoAString</value></member>
50+
<member><name>bool_true</name><value><boolean>1</boolean></value></member>
51+
<member><name>bool_false</name><value><boolean>0</boolean></value></member>
52+
<member><name>i4</name><value><i4>-23</i4></value></member>
53+
<member><name>int</name><value><int>42</int></value></member>
54+
<member><name>base64_empty</name><value><base64></base64></value></member>
55+
<member><name>base64_data</name><value><base64>YW55X2RhdGE=</base64></value></member>
56+
</struct>
57+
</value></param></params>
58+
</methodCall>"""
59+
60+
parsed = XmlRpcRequest.parse(body)
61+
self.assertEqual('D222', parsed.members['TNB'])
62+
self.assertEqual('2025-01-01', parsed.members['port_date'])
63+
self.assertEqual('', parsed.members['emptyString'])
64+
self.assertEqual('thisIsAlsoAString', parsed.members['stringWithoutType'])
65+
self.assertEqual(True, parsed.members['bool_true'])
66+
self.assertEqual(False, parsed.members['bool_false'])
67+
self.assertEqual(-23, parsed.members['i4'])
68+
self.assertEqual(42, parsed.members['int'])
69+
self.assertEqual(b'', parsed.members['base64_empty'])
70+
self.assertEqual(b'any_data', parsed.members['base64_data'])
71+
72+
def test_invalid_members(self) -> None:
73+
members = [
74+
{'name': 'bool_empty', 'value': '<boolean></boolean>'},
75+
{'name': 'bool_string', 'value': '<boolean>true</boolean>'},
76+
{'name': 'int_text', 'value': '<int>fortytwo</int>'},
77+
{'name': 'int_empty', 'value': '<i4></i4>'}
78+
]
79+
80+
for member in members:
81+
with self.subTest(member['name']):
82+
body = f"""<?xml version="1.0"?>
83+
<methodCall>
84+
<methodName>a_method_name</methodName>
85+
<params><param><value>
86+
<struct>
87+
<member><name>{member['name']}</name><value>{member['value']}</value></member>
88+
</struct>
89+
</value></param></params>
90+
</methodCall>"""
91+
92+
with self.assertRaises(ValueError):
93+
XmlRpcRequest.parse(body)
94+
95+
def test_unsupported_data_type(self) -> None:
96+
body = """<?xml version="1.0"?>
97+
<methodCall>
98+
<methodName>a_method_name</methodName>
99+
<params><param><value>
100+
<struct>
101+
<member><name>PI</name><value><float>3.14</float></value></member>
102+
</struct>
103+
</value></param></params>
104+
</methodCall>"""
105+
106+
with self.assertRaises(NotImplementedError):
107+
XmlRpcRequest.parse(body)
108+
109+
def test_has_string_representation(self) -> None:
110+
body = """<?xml version="1.0"?>
111+
<methodCall>
112+
<methodName>another_method_name</methodName>
113+
<params><param><value>
114+
<struct>
115+
<member><name>TNB</name><value><string>D111</string></value></member>
116+
</struct>
117+
</value></param></params>
118+
</methodCall>"""
119+
120+
parsed = XmlRpcRequest.parse(body)
121+
self.assertRegex(f"{parsed}", "^<XmlRpcRequest.*methodName='another_method_name'.*>$")
122+
self.assertIn('TNB', f"{parsed}")
123+
self.assertIn('D111', f"{parsed}")
124+
125+
def test_also_accepts_bytes(self) -> None:
126+
body = b"""<?xml version="1.0"?>
127+
<methodCall>
128+
<methodName>a_method_name</methodName>
129+
<params><param><value></value></param></params>
130+
</methodCall>"""
131+
132+
XmlRpcRequest.parse(body)
133+
134+
def test_serialization_empty_request(self) -> None:
135+
request = XmlRpcRequest('a_method', {})
136+
137+
expected_body = """<?xml version="1.0"?>
138+
<methodCall>
139+
<methodName>a_method</methodName>
140+
<params></params>
141+
</methodCall>"""
142+
143+
# TODO: use better comparison, this would ignore spaces in values
144+
self.assertEqual(''.join(expected_body.split()), ''.join(request.serialize().split()))
145+
146+
def test_serialization(self) -> None:
147+
request = XmlRpcRequest('a_method', {
148+
'an_int': 42,
149+
'a_string': 'the_value',
150+
'a_struct': {
151+
'another_int': 23
152+
},
153+
'an_array': [{
154+
'another_str': 'the_other_value'
155+
}]
156+
})
157+
158+
expected_body = """<?xml version="1.0"?>
159+
<methodCall>
160+
<methodName>a_method</methodName>
161+
<params><param><value>
162+
<struct>
163+
<member><name>an_int</name><value><i4>42</i4></value></member>
164+
<member><name>a_string</name><value><string>the_value</string></value></member>
165+
<member><name>a_struct</name><value><struct>
166+
<member><name>another_int</name><value><i4>23</i4></value></member>
167+
</struct></value></member>
168+
<member><name>an_array</name><value><array>
169+
<data>
170+
<value><struct>
171+
<member><name>another_str</name><value><string>the_other_value</string></value></member>
172+
</struct></value>
173+
</data>
174+
</array></value></member>
175+
</struct>
176+
</value></param></params>
177+
</methodCall>"""
178+
179+
# TODO: use better comparison, this would ignore spaces in values
180+
self.assertEqual(''.join(expected_body.split()), ''.join(request.serialize().split()))

0 commit comments

Comments
 (0)