Files
amq-adapter-python/amqp/adapter/data_message_factory.py
T
2025-04-22 08:50:16 +01:00

258 lines
8.9 KiB
Python

"""
methods to create, serialize and deserialize DataMessage objects.
"""
import base64
import json
import re
from io import BytesIO
from datetime import datetime, timezone
from typing import Dict, List
import amqp.adapter.file_handler
from amqp.model.model import DataMessage
from amqp.model.snowflake_id import SnowflakeId
from amqp.adapter.serializer import (
map_with_list_as_string,
map_as_string,
parse_map_list_string,
parse_map_string,
)
from amqp.router.utils import sanitize_as_rabbitmq_name
from amqp.adapter.file_handler import StreamingFileHandler, FileHandler
class DataMessageFactory:
SNOWFLAKE_ID_BITS = 80
SEQUENCE_BITS = 12
MACHINE_ID_BITS = 24
TIMESTAMP_BITS = SNOWFLAKE_ID_BITS - SEQUENCE_BITS - MACHINE_ID_BITS
MACHINE_ID_MASK = (1 << MACHINE_ID_BITS) - 1
SEQUENCE_MASK = (1 << SEQUENCE_BITS) - 1
MAGIC_BYTE_HTTP_REQUEST = "A"
_instance = None
snowflake_epoch = datetime(2024, 10, 1, 0, 0, tzinfo=timezone.utc).timestamp()
def __init__(self, machine_id: int):
self.machine_id = (machine_id & self.MACHINE_ID_MASK) << self.SEQUENCE_BITS
self.snowflake_sequence = 0
self.snowflake_last_timestamp = 0
@classmethod
def get_instance(cls, machine_id: int):
"""
return reusable factory instance
:param machine_id: a generator ID, should be unique for each component to prevent duplicate IDs
:return: factory instance
"""
if cls._instance is None:
cls._instance = DataMessageFactory(machine_id)
return cls._instance
def generate_snowflake_id(self) -> SnowflakeId:
"""
Generate an unique ID following the SnowflakeID structure, see https://en.wikipedia.org/wiki/Snowflake_ID
:return: 80-bit wide ID
"""
timestamp = int(datetime.now(timezone.utc).timestamp() * 1000)
if timestamp < self.snowflake_last_timestamp:
raise RuntimeError("Clock moved backwards. Refusing to generate id")
if timestamp == self.snowflake_last_timestamp:
self.snowflake_sequence = (self.snowflake_sequence + 1) & self.SEQUENCE_MASK
if self.snowflake_sequence == 0:
while timestamp == self.snowflake_last_timestamp:
timestamp = int(datetime.now(timezone.utc).timestamp() * 1000)
self.snowflake_last_timestamp = timestamp
else:
self.snowflake_sequence = 0
self.snowflake_last_timestamp = timestamp
time = timestamp - 1000 * int(DataMessageFactory.snowflake_epoch)
return SnowflakeId(
time >> (64 - self.SEQUENCE_BITS - self.MACHINE_ID_BITS),
(
(time << (self.SEQUENCE_BITS + self.MACHINE_ID_BITS))
| self.machine_id
| self.snowflake_sequence
)
& 0xFFFFFFFFFFFFFFFF,
)
def create_request_message(
self,
method: str,
domain: str,
path: str,
headers: Dict[str, List[str]],
message,
) -> DataMessage:
"""
Encapsulate / hide the system level inputs, create the AMQ message from business relevant input
:param method: a method requested (e.g. HTTP method like GET, POST in case of REST request)
:param domain: service host name
:param path: path at the service
:param headers: HTTP headers
:param message: payload
:return: DataMessage object
"""
serializable_headers = headers.copy() if headers else {}
payload = base64.b64encode(
message.encode("utf-8") if isinstance(message, str) else message
)
trace_info = {}
return DataMessage(
self.MAGIC_BYTE_HTTP_REQUEST,
self.generate_snowflake_id(),
method,
domain,
path,
serializable_headers,
trace_info,
payload,
)
@staticmethod
def amq_message_routing_key(message: DataMessage) -> str:
"""
Constructs the message specific routing key.
:param message: DataMessage input
:return: routing key (as string) compliant to RabbitMQ allowed character set.
"""
return sanitize_as_rabbitmq_name(f"{message.domain}.{message.path}")
@staticmethod
def get_timestamp_from_id(_id: SnowflakeId) -> int:
"""
Helper method - retrieve the timestamp from the ID
:param _id: SnowflakeID (includes encoded timestamp)
:return: the message timestamp
"""
bits = _id.get_bits()
low = bits[0] >> (
DataMessageFactory.SEQUENCE_BITS + DataMessageFactory.MACHINE_ID_BITS
)
high = bits[1] << (
64 - DataMessageFactory.SEQUENCE_BITS - DataMessageFactory.MACHINE_ID_BITS
)
return (high | low) + DataMessageFactory.snowflake_epoch * 1000
@staticmethod
def to_string(message: DataMessage) -> str:
"""
Human-readable representation.
:param message: DataMessage input
:return: as string
"""
return (
f"DataMessage{{"
f"id={message.id}, "
f"method='{message.method}', "
f"domain='{message.domain}', "
f"path='{message.path}', "
f"headers={{{map_with_list_as_string(message.headers)}}}, "
f"traceInfo={{{map_as_string(message.trace_info)}}}, "
f"base64body={message.base64_body.decode()}"
f"}}"
)
@staticmethod
def serialize(message: DataMessage) -> bytes:
"""
Converts DataMessage to byte array
:param message: message to serialize
:return: messages as bytes
"""
id_bytes = str(message.id).encode()
prolog = (
f"|m={message.method}|d={message.domain}|p={message.path}|h={{{map_with_list_as_string(message.headers)}}}|t={{{map_as_string(message.trace_info)}}}|b="
).encode("utf-8")
header_length = 1 + len(id_bytes) + len(prolog)
prolog_len = header_length.to_bytes(2, byteorder="big")
msg_length = 2 + header_length + len(message.base64_body)
with BytesIO(bytearray(msg_length)) as baos:
baos.write(prolog_len)
baos.write(DataMessageFactory.MAGIC_BYTE_HTTP_REQUEST.encode("utf-8"))
baos.write(id_bytes)
baos.write(prolog)
baos.write(message.base64_body)
return baos.getvalue()
@staticmethod
def from_bytes(input_bytes: bytes) -> DataMessage:
"""
Builds the DataMessage from its serialized (byte array) form.
:param input_bytes: serialized message
:return: DataMessage instance
"""
prolog_len = (input_bytes[0] << 8) + input_bytes[1]
metadata = input_bytes[2 : prolog_len + 2].decode()
pattern = re.compile(
f"{DataMessageFactory.MAGIC_BYTE_HTTP_REQUEST}"
r"([0-9A-Fa-f]+)\|"
r"m=([^|]*)\|"
r"d=([^|]*)\|"
r"p=([^|]*)\|"
r"h=(\{[^}]*\})\|"
r"t=(\{[^}]*\})\|"
r"b="
)
match = pattern.match(metadata)
if not match:
raise ValueError(
f"DataMessage format does not match expected format. "
f"Input string [{metadata}] does not match pattern: {pattern.pattern}"
)
id_str = match.group(1)
method = match.group(2)
domain = match.group(3)
path = match.group(4)
headers_str = match.group(5)
trace_info_str = match.group(6)
body_b64 = input_bytes[prolog_len + 2 :]
headers = parse_map_list_string(headers_str)
trace_info = parse_map_string(trace_info_str)
return DataMessage(
DataMessageFactory.MAGIC_BYTE_HTTP_REQUEST,
SnowflakeId.from_hex(id_str),
method,
domain,
path,
headers,
trace_info,
body_b64,
)
@staticmethod
async def from_stream(
stream: bytes,
rabbitmq_url: str,
loop,
file_handler: FileHandler = StreamingFileHandler(),
) -> DataMessage | None:
"""
Builds the DataMessage from its serialized (byte array) form.
:param stream: serialized message
:param rabbitmq_url: RabbitMQ connection URL
:param loop: event loop
:param file_handler: file handler for downloading the message
:return: DataMessage instance
"""
# async implementation
_message_data = json.loads(stream.decode())
_req_info = _message_data.get("req-info", "")
if _req_info:
(body, eof) = await file_handler.download_buffer(
loop=loop, rabbitmq_url=rabbitmq_url, queue_name=_req_info
)
if eof == amqp.adapter.file_handler.END_OF_FILE:
return DataMessageFactory.from_bytes(body)
return None