How-to — Communication Protocols
The Basics
To define your communication protocol, you must instantiate one of the following protocol objects:
DatagramProtocol
: suitable for datagram oriented communication (e.g. UDP).StreamProtocol
: suitable for stream oriented communication (e.g. TCP).
They all have one thing in common: they wrap a serializer and a converter.
You can either directly create an instance:
1from __future__ import annotations
2
3from typing import Any, TypeAlias
4
5from easynetwork.protocol import DatagramProtocol
6from easynetwork.serializers import JSONSerializer
7
8SentPacket: TypeAlias = Any
9ReceivedPacket: TypeAlias = Any
10
11json_protocol: DatagramProtocol[SentPacket, ReceivedPacket] = DatagramProtocol(JSONSerializer())
1from __future__ import annotations
2
3from typing import Any, TypeAlias
4
5from easynetwork.protocol import StreamProtocol
6from easynetwork.serializers import JSONSerializer
7
8SentPacket: TypeAlias = Any
9ReceivedPacket: TypeAlias = Any
10
11json_protocol: StreamProtocol[SentPacket, ReceivedPacket] = StreamProtocol(JSONSerializer())
or create a subclass:
1from __future__ import annotations
2
3from typing import Any, TypeAlias
4
5from easynetwork.protocol import DatagramProtocol
6from easynetwork.serializers import JSONSerializer
7
8SentPacket: TypeAlias = Any
9ReceivedPacket: TypeAlias = Any
10
11
12class JSONDatagramProtocol(DatagramProtocol[SentPacket, ReceivedPacket]):
13 def __init__(self) -> None:
14 serializer = JSONSerializer()
15 super().__init__(serializer)
16
17
18json_protocol = JSONDatagramProtocol()
1from __future__ import annotations
2
3from typing import Any, TypeAlias
4
5from easynetwork.protocol import StreamProtocol
6from easynetwork.serializers import JSONSerializer
7
8SentPacket: TypeAlias = Any
9ReceivedPacket: TypeAlias = Any
10
11
12class JSONStreamProtocol(StreamProtocol[SentPacket, ReceivedPacket]):
13 def __init__(self) -> None:
14 serializer = JSONSerializer()
15 super().__init__(serializer)
16
17
18json_protocol = JSONStreamProtocol()
Tip
The latter is recommended. The main advantage of this model is to declaratively define the communication protocol (the name of the class being that of the protocol, the types of objects sent and received, etc.).
Another advantage is that the serializer (and converter, if any) can be configured in a single place in the project.
Usage
The protocol objects are requested by endpoint and server implementations to handle the data sent and received:
1def main() -> None:
2 protocol = JSONDatagramProtocol()
3
4 with UDPNetworkClient(("remote_address", 12345), protocol) as endpoint:
5 endpoint.send_packet({"data": 42})
6
7 ...
1def main() -> None:
2 protocol = JSONStreamProtocol()
3
4 with TCPNetworkClient(("remote_address", 12345), protocol) as endpoint:
5 endpoint.send_packet({"data": 42})
6
7 ...
Warning
A protocol object is intended to be shared by multiple endpoints. Do not store sensitive data in these objects. You might see some magic.
Parsing Error
The protocol objects raise a BaseProtocolParseError
subclass when the received data is invalid:
The raised exception is DatagramProtocolParseError
.
1try:
2 received_packet = endpoint.recv_packet()
3except DatagramProtocolParseError:
4 print("The received data is invalid.")
5else:
6 print(f"Received {received_packet!r} from {endpoint.get_remote_address()}.")
The raised exception is StreamProtocolParseError
.
1try:
2 received_packet = endpoint.recv_packet()
3except StreamProtocolParseError:
4 print("The received data is invalid.")
5else:
6 print(f"Received {received_packet!r} from {endpoint.get_remote_address()}.")
Tip
The underlying DeserializeError
instance is available with the error
attribute.
The Converters
TL;DR: Why should you always have a converter in your protocol object?
Unless the serializer is already making the tea and coffee for you, in 99% of cases the data received can be anything, as long as it’s in the right format. On the other hand, the application has to comply with the format for sending data to the remote endpoint.
However, you just want to be able to manipulate your business objects without having to worry about such problems.
This is what a converter can do for you. It creates a DTO suitable for the underlying serializer and validates the received DTO to recreate the business object.
Writing A Converter
To write a converter, you must create a subclass of AbstractPacketConverter
and override
its convert_to_dto_packet()
and create_from_dto_packet()
methods.
For example:
1from __future__ import annotations
2
3import dataclasses
4import uuid
5from typing import Any, TypeGuard
6
7from easynetwork.converter import AbstractPacketConverter
8from easynetwork.exceptions import PacketConversionError
9
10
11@dataclasses.dataclass
12class Person:
13 id: uuid.UUID
14 name: str
15 age: int
16 friends: list[uuid.UUID] = dataclasses.field(default_factory=list)
17
18
19class PersonToJSONConverter(AbstractPacketConverter[Person, dict[str, Any]]):
20 def convert_to_dto_packet(self, person: Person, /) -> dict[str, Any]:
21 return {
22 "id": str(person.id),
23 "name": person.name,
24 "age": person.age,
25 "friends": [str(friend_uuid) for friend_uuid in person.friends],
26 }
27
28 def create_from_dto_packet(self, packet: dict[str, Any], /) -> Person:
29 match packet:
30 case {
31 "id": str(person_id),
32 "name": str(name),
33 "age": int(age),
34 "friends": list(friends),
35 } if self._is_valid_list_of_friends(friends):
36 try:
37 person = Person(
38 id=uuid.UUID(person_id),
39 name=name,
40 age=age,
41 friends=[uuid.UUID(friend_id) for friend_id in friends],
42 )
43 except ValueError: # Invalid UUIDs
44 raise PacketConversionError("Invalid UUID fields") from None
45
46 case _:
47 raise PacketConversionError("Invalid packet format")
48
49 return person
50
51 @staticmethod
52 def _is_valid_list_of_friends(friends_list: list[Any]) -> TypeGuard[list[str]]:
53 return all(isinstance(friend_id, str) for friend_id in friends_list)
Warning
The create_from_dto_packet()
function must raise a PacketConversionError
to indicate that
a parsing error was “expected” so that the received data is considered invalid.
Otherwise, any other error is considered a crash.
This converter can now be used in our protocol object:
1class PersonProtocol(StreamProtocol[Person, Person]):
2 def __init__(self) -> None:
3 serializer = JSONSerializer()
4 converter = PersonToJSONConverter()
5
6 super().__init__(serializer, converter=converter)
Note
Now this protocol is annotated to send and receive a Person
object.
In the application, you can now safely handle an object with real meaning:
1def main() -> None:
2 protocol = PersonProtocol()
3
4 with TCPNetworkClient(("remote_address", 12345), protocol) as endpoint:
5 john_doe = Person(
6 id=uuid.uuid4(),
7 name="John Doe",
8 age=36,
9 friends=[uuid.uuid4() for _ in range(5)],
10 )
11
12 # Person object directly sent
13 endpoint.send_packet(john_doe)
14
15 try:
16 # The received object should be a Person instance.
17 received_person = endpoint.recv_packet()
18 except StreamProtocolParseError as exc:
19 match exc.error:
20 case DeserializeError():
21 print("It is not even a JSON object.")
22 case PacketConversionError():
23 print("It is not a valid Person in JSON object.")
24 else:
25 assert isinstance(received_person, Person)
26 print(f"Received person: {received_person!r}")
Writing A Composite Converter
Most of the time, the packets sent and received are different (the request/response system). To deal with this, a protocol object accepts a composite converter.
To write a composite converter, there are two ways described below.
Note
Do what you think is best, there is no recommended method.
Option 1: Using StapledPacketConverter
1from __future__ import annotations
2
3from typing import Any
4
5from easynetwork.converter import AbstractPacketConverter, StapledPacketConverter
6from easynetwork.protocol import StreamProtocol
7from easynetwork.serializers import JSONSerializer
8
9
10class Request:
11 ...
12
13
14class Response:
15 ...
16
17
18class RequestConverter(AbstractPacketConverter[Request, dict[str, Any]]):
19 def convert_to_dto_packet(self, request: Request, /) -> dict[str, Any]:
20 ...
21
22 def create_from_dto_packet(self, request_dict: dict[str, Any], /) -> Request:
23 ...
24
25
26class ResponseConverter(AbstractPacketConverter[Response, dict[str, Any]]):
27 def convert_to_dto_packet(self, response: Response, /) -> dict[str, Any]:
28 ...
29
30 def create_from_dto_packet(self, response_dict: dict[str, Any], /) -> Response:
31 ...
32
33
34serializer = JSONSerializer()
35request_converter = RequestConverter()
36response_converter = ResponseConverter()
37
38client_protocol: StreamProtocol[Request, Response] = StreamProtocol(
39 serializer=serializer,
40 converter=StapledPacketConverter(
41 sent_packet_converter=request_converter,
42 received_packet_converter=response_converter,
43 ),
44)
45server_protocol: StreamProtocol[Response, Request] = StreamProtocol(
46 serializer=serializer,
47 converter=StapledPacketConverter(
48 sent_packet_converter=response_converter,
49 received_packet_converter=request_converter,
50 ),
51)
Option 2: By Subclassing AbstractPacketConverterComposite
1from __future__ import annotations
2
3from typing import Any
4
5from easynetwork.converter import AbstractPacketConverterComposite
6from easynetwork.protocol import StreamProtocol
7from easynetwork.serializers import JSONSerializer
8
9
10class Request:
11 ...
12
13
14class Response:
15 ...
16
17
18class ClientConverter(AbstractPacketConverterComposite[Request, Response, dict[str, Any], dict[str, Any]]):
19 def convert_to_dto_packet(self, request: Request, /) -> dict[str, Any]:
20 ...
21
22 def create_from_dto_packet(self, response_dict: dict[str, Any], /) -> Response:
23 ...
24
25
26class ServerConverter(AbstractPacketConverterComposite[Response, Request, dict[str, Any], dict[str, Any]]):
27 def convert_to_dto_packet(self, response: Response, /) -> dict[str, Any]:
28 ...
29
30 def create_from_dto_packet(self, request_dict: dict[str, Any], /) -> Request:
31 ...
32
33
34serializer = JSONSerializer()
35
36client_protocol: StreamProtocol[Request, Response] = StreamProtocol(
37 serializer=serializer,
38 converter=ClientConverter(),
39)
40server_protocol: StreamProtocol[Response, Request] = StreamProtocol(
41 serializer=serializer,
42 converter=ServerConverter(),
43)