Added support for file download

This commit is contained in:
Stanislav Hejny
2025-04-09 19:23:26 +01:00
parent 6b420dee0a
commit 113dd38106
16 changed files with 250 additions and 185 deletions
+92 -83
View File
@@ -2,25 +2,45 @@
The layer that interfaces with the CleverThis service that this Adapter is integrating with. The layer that interfaces with the CleverThis service that this Adapter is integrating with.
Methods here will invoke the actual CleverThis Service code, which are passed as Callable object. Methods here will invoke the actual CleverThis Service code, which are passed as Callable object.
""" """
import asyncio
import inspect
import json import json
import logging import logging
import re
import sys import sys
import traceback import traceback
from re import Pattern from asyncio import Future
from typing import Dict, List, Tuple, Callable, Coroutine, AnyStr, Any from typing import Dict, List, Tuple, Callable, Coroutine, Any
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from xml.etree import ElementTree from xml.etree import ElementTree
from aio_pika.abc import AbstractRobustChannel
from aiohttp import ClientSession, ClientResponse from aiohttp import ClientSession, ClientResponse
from dataclasses import dataclass
from fastapi import HTTPException from fastapi import HTTPException
from starlette.responses import FileResponse
from amqp.adapter import pydantic_serializer
from amqp.adapter.data_response_factory import DataResponseFactory from amqp.adapter.data_response_factory import DataResponseFactory
from amqp.adapter.pydantic_serializer import JSONConverter
from amqp.config.amq_configuration import AMQConfiguration from amqp.config.amq_configuration import AMQConfiguration
from amqp.model.model import DataMessage, DataResponse from amqp.model.model import DataMessage, DataResponse
from amqp.service.multipart_parser import CleverMultiPartParser from amqp.adapter.multipart_parser import CleverMultiPartParser, process_form_data
from amqp.adapter.file_uploader import publish_file_to_rabbitmq
@dataclass
class AMQMessage(DataMessage):
def __init__(self, data_message: DataMessage, channel: AbstractRobustChannel):
# Copy all fields from DataMessage
self.magic = data_message.magic
self.id = data_message.id
self.method = data_message.method
self.domain = data_message.domain
self.path = data_message.path
self.headers = data_message.headers
self.trace_info = data_message.trace_info
self.base64_body = data_message.base64_body
# Add the new field
self.channel = channel
def parse_request_body(message: DataMessage): def parse_request_body(message: DataMessage):
@@ -42,17 +62,22 @@ def parse_request_body(message: DataMessage):
try: try:
return json.loads(message.body()) return json.loads(message.body())
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
tb = traceback.extract_tb(sys.exc_info()[2])[-1] _tb = traceback.extract_tb(sys.exc_info()[2])[-1]
logging.error(f" [E] Error in {tb.filename}, line {tb.lineno}, function '{tb.name}': {e}") logging.error(f" [E] Error in {_tb.filename}, line {_tb.lineno}, function '{_tb.name}': {e}")
raise ValueError(f"Invalid JSON: {e}") raise ValueError(f"Invalid JSON: {e}")
# Parse URL-encoded form data # Parse URL-encoded form data
elif content_type == "application/x-www-form-urlencoded": elif content_type == "application/x-www-form-urlencoded":
try: try:
return {k: v[0] if len(v) == 1 else v for k, v in parse_qs(message.body()).items()} _form_data: dict = {k: v[0] if len(v) == 1 else v for k, v in parse_qs(message.body()).items()}
if len(_form_data) == 0 and len(message.body()) > 0:
_form_data = json.loads(message.body())
if _form_data['files'] and _form_data['form']:
_form_data = process_form_data(_form_data)
return _form_data
except Exception as e: except Exception as e:
tb = traceback.extract_tb(sys.exc_info()[2])[-1] _tb = traceback.extract_tb(sys.exc_info()[2])[-1]
logging.error(f" [E] Error in {tb.filename}, line {tb.lineno}, function '{tb.name}': {e}") logging.error(f" [E] Error in {_tb.filename}, line {_tb.lineno}, function '{_tb.name}': {e}")
raise ValueError(f"Invalid URL-encoded form data: {e}") raise ValueError(f"Invalid URL-encoded form data: {e}")
# Parse multipart/form-data # Parse multipart/form-data
@@ -61,8 +86,8 @@ def parse_request_body(message: DataMessage):
parser: CleverMultiPartParser = CleverMultiPartParser(message) parser: CleverMultiPartParser = CleverMultiPartParser(message)
return parser.form_data return parser.form_data
except Exception as e: except Exception as e:
tb = traceback.extract_tb(sys.exc_info()[2])[-1] _tb = traceback.extract_tb(sys.exc_info()[2])[-1]
logging.error(f" [E] Error in {tb.filename}, line {tb.lineno}, function '{tb.name}': {e}") logging.error(f" [E] Error in {_tb.filename}, line {_tb.lineno}, function '{_tb.name}': {e}")
raise ValueError(f"Invalid multipart/form-data: {e}") raise ValueError(f"Invalid multipart/form-data: {e}")
# Parse plain text # Parse plain text
@@ -72,8 +97,8 @@ def parse_request_body(message: DataMessage):
# Parse XML # Parse XML
elif content_type == "application/xml": elif content_type == "application/xml":
try: try:
root = ElementTree.fromstring(message.body()) _root = ElementTree.fromstring(message.body())
return {elem.tag: elem.text for elem in root} return {elem.tag: elem.text for elem in _root}
except ElementTree.ParseError as e: except ElementTree.ParseError as e:
raise ValueError(f"Invalid XML: {e}") raise ValueError(f"Invalid XML: {e}")
@@ -81,8 +106,22 @@ def parse_request_body(message: DataMessage):
raise ValueError(f"Unsupported Content-Type: {content_type}") raise ValueError(f"Unsupported Content-Type: {content_type}")
async def call_cleverthis_post_put_api( async def convert_file_response(obj: FileResponse, channel: AbstractRobustChannel) -> str:
message: DataMessage, """
Converts a FileResponse object to a JSON string containing the filename and stream.
"""
_future: Future = asyncio.run_coroutine_threadsafe(
publish_file_to_rabbitmq(channel, obj.path), loop=channel.channel.loop)
return json.dumps({'files':[
{'filename': obj.filename,
'mediaType': 'application/octet-stream',
'streamName': _future.result()
}]
})
async def call_cleverthis_api(
message: AMQMessage,
args: Any, args: Any,
api_method: Callable[[Any, Any], Coroutine], api_method: Callable[[Any, Any], Coroutine],
success_code: int success_code: int
@@ -98,19 +137,34 @@ async def call_cleverthis_post_put_api(
:return: DataResponse class :return: DataResponse class
""" """
try: try:
result: str = await api_method(**args) _verified_args = {}
return DataResponseFactory.of( _sig = inspect.signature(api_method)
message.id, success_code, message.headers.get("Content-Type", "application/json"), _required_args = list(_sig.parameters.values())
result.encode('utf-8'), message.trace_info #_required_args = getattr(api_method, '__annotations__', {})
) for arg in _required_args:
if arg.name in args:
_verified_args[arg.name] = args[arg.name]
else:
logging.info(f"{api_method.__name__}: Missing required argument: {arg.name}")
logging.info(
f" [*] INVOKE ENDPOINT: {api_method.__name__}, ARGS: {', '.join(f'{k}={v}' for k, v in _verified_args.items())}")
_result: Any = await api_method(**_verified_args)
logging.info(f" [*] ENDPOINT RESULT: {api_method.__name__}, RESULT: {str(_result)}")
_json_result, _content_type = await convert_file_response(_result, message.channel), 'application/octet-stream' \
if isinstance(_result, FileResponse) else _result, 'application/json'
_binary_result = _json_result.encode('utf-8') if hasattr(_json_result, 'encode') else \
_json_result.body if hasattr(_json_result, 'body') else \
_json_result.read() if hasattr(_json_result, 'read') else \
_json_result
return DataResponseFactory.of(message.id, success_code, _content_type, _binary_result, message.trace_info)
except HTTPException as http_error: except HTTPException as http_error:
tb = traceback.extract_tb(sys.exc_info()[2])[-1] _tb = traceback.extract_tb(sys.exc_info()[2])[-1]
logging.error(f" [E] Error in {tb.filename}, line {tb.lineno}, function '{tb.name}': {http_error}") logging.error(f" [E] Error in {_tb.filename}, line {_tb.lineno}, function '{_tb.name}': {http_error}")
return DataResponseFactory.of(message.id, http_error.status_code, "text/plain", return DataResponseFactory.of(message.id, http_error.status_code, "text/plain",
str(http_error.detail).encode('utf-8'), message.trace_info) str(http_error.detail).encode('utf-8'), message.trace_info)
except Exception as error: except Exception as error:
tb = traceback.extract_tb(sys.exc_info()[2])[-1] _tb = traceback.extract_tb(sys.exc_info()[2])[-1]
logging.error(f" [E] Error in {tb.filename}, line {tb.lineno}, function '{tb.name}': {error}") logging.error(f" [E] Error in {_tb.filename}, line {_tb.lineno}, function '{_tb.name}': {error}")
return DataResponseFactory.of(message.id, 500, "text/plain", str(error).encode('utf-8'), message.trace_info) return DataResponseFactory.of(message.id, 500, "text/plain", str(error).encode('utf-8'), message.trace_info)
@@ -126,61 +180,11 @@ def parse_get_url(url: str) -> Tuple[str, Dict[str, str]]:
- context_path: The path part of the URL (e.g., "/path") - context_path: The path part of the URL (e.g., "/path")
- query_params_dict: Query parameters where values are str for single value or list of str for multivalue - query_params_dict: Query parameters where values are str for single value or list of str for multivalue
""" """
parsed = urlparse(url) _parsed = urlparse(url)
query_params = parse_qs(parsed.query) _query_params = parse_qs(_parsed.query)
# Convert values from lists to single values when there's only one value # Convert values from lists to single values when there's only one value
simplified_params = {k: v[0] if len(v) == 1 else v for k, v in query_params.items()} _simplified_params = {k: v[0] if len(v) == 1 else v for k, v in _query_params.items()}
return parsed.path, simplified_params return _parsed.path, _simplified_params
async def call_cleverthis_get_api(
message: DataMessage,
pattern_or_data: Pattern| str | dict,
api_method: Callable[[Tuple[AnyStr | Any, ...], Any], Coroutine],
authenticated_user: Any
) -> DataResponse:
"""
Handles GET requests, which do not contain a body, but may have path variables and extra values
in the query string.
invokes provided Callable that is expected to be a GET API method, and returns the response as DataResponse
:param message: DataMessage containing the details of the call
:param pattern_or_data: If there are path variables, use this pattern to extract them
:param api_method: Callable - the method to invoke.
:param authenticated_user: Authenticated user. The actual class depends on the service
:return:
"""
try:
args = None
if isinstance(pattern_or_data, dict):
args = pattern_or_data
elif isinstance(pattern_or_data, Pattern):
path, args = pydantic_serializer.parse_url_query(message.path)
match = pattern_or_data.match(path)
if match:
args.update(match.groupdict())
else:
_msg = f"Unexpected URL: {message.path}, expected pattern: {pattern_or_data}"
return DataResponseFactory.of(message.id, 404, "text/plain", _msg.encode('utf-8'), message.trace_info)
else:
path, args = pydantic_serializer.parse_url_query(message.path)
match = re.search(pattern_or_data, path)
if match:
args.update(match.groupdict())
else:
_msg = f"Unexpected URL: {message.path}, expected pattern: {pattern_or_data}"
return DataResponseFactory.of(message.id, 404, "text/plain", _msg.encode('utf-8'), message.trace_info)
crt: Coroutine = api_method(args, authenticated_user)
result: str = await crt
return DataResponseFactory.of(message.id, 200, "text/plain", result.encode('utf-8'), message.trace_info)
except HTTPException as http_error:
tb = traceback.extract_tb(sys.exc_info()[2])[-1]
logging.error(f" [E] Error in {tb.filename}, line {tb.lineno}, function '{tb.name}': {http_error}")
return DataResponseFactory.of(message.id, http_error.status_code, "text/plain",
str(http_error.detail).encode('utf-8'), message.trace_info)
except Exception as error:
tb = traceback.extract_tb(sys.exc_info()[2])[-1]
logging.error(f" [E] Error in {tb.filename}, line {tb.lineno}, function '{tb.name}': {error}")
return DataResponseFactory.of(message.id, 500, "text/plain", str(error).encode('utf-8'), message.trace_info)
# ============= CleverThisServiceAdapter ============= # ============= CleverThisServiceAdapter =============
@@ -192,6 +196,11 @@ class CleverThisServiceAdapter:
and override the methods to handle the specific API calls. and override the methods to handle the specific API calls.
""" """
def __init__(self, amq_configuration: AMQConfiguration, session: ClientSession = None): def __init__(self, amq_configuration: AMQConfiguration, session: ClientSession = None):
"""
Initializes the CleverThisServiceAdapter with the given AMQ configuration and optional HTTP session.
The HTTP session is for the case when the adapter uses loose coupling with the CleverThis service via
HTTP REST API.
"""
self.amq_configuration = amq_configuration self.amq_configuration = amq_configuration
self.session = session self.session = session
self.service_host = self.amq_configuration.dispatch.service_host self.service_host = self.amq_configuration.dispatch.service_host
@@ -218,14 +227,14 @@ class CleverThisServiceAdapter:
""" """
return None return None
async def on_message(self, message) -> DataResponse: async def on_message(self, message: AMQMessage) -> DataResponse:
""" """
Called when a message is received from the AMQP. Called when a message is received from the AMQP.
:param message: :param message:
:return: AMQP response class with operation status code and optional error details. :return: AMQP response class with operation status code and optional error details.
""" """
_auth_user: Any | None = None _auth_user: Any | None = None
amq_response: DataResponse | None = None amq_response: Any | None = None
if self.require_authenticated_user: if self.require_authenticated_user:
_auth: str = message.headers.get('Authorization', '') _auth: str = message.headers.get('Authorization', '')
@@ -317,7 +326,7 @@ class CleverThisServiceAdapter:
) )
async def handle_possible_form(self, message: DataMessage, request_coroutine, auth_user: Any) -> DataResponse: async def handle_possible_form(self, message: DataMessage, request_coroutine, auth_user: Any) -> DataResponse:
args = parse_request_body(message) _args = parse_request_body(message)
return await request_coroutine return await request_coroutine
async def head(self, message: DataMessage, auth_user: Any) -> DataResponse: async def head(self, message: DataMessage, auth_user: Any) -> DataResponse:
+1 -1
View File
@@ -11,7 +11,7 @@ from typing import Dict, List
from amqp.model.model import DataMessage from amqp.model.model import DataMessage
from amqp.model.snowflake_id import SnowflakeId from amqp.model.snowflake_id import SnowflakeId
from amqp.router.serializer import map_with_list_as_string, map_as_string, parse_map_list_string, parse_map_string 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.router.utils import sanitize_as_rabbitmq_name
from amqp.adapter.file_downloader import download_buffer from amqp.adapter.file_downloader import download_buffer
+1 -1
View File
@@ -7,7 +7,7 @@ import re
from io import BytesIO from io import BytesIO
from amqp.model.model import DataResponse from amqp.model.model import DataResponse
from amqp.model.snowflake_id import SnowflakeId from amqp.model.snowflake_id import SnowflakeId
from amqp.router.serializer import parse_map_string, map_as_string from amqp.adapter.serializer import parse_map_string, map_as_string
class DataResponseFactory: class DataResponseFactory:
+2 -2
View File
@@ -162,8 +162,8 @@ async def on_message(
files_to_download.append(files_info) files_to_download.append(files_info)
if not files_to_download: if not files_to_download:
logging.info("No files to download in this message.") logging.info(" [%s] No files to download in this message.", message.delivery_tag)
await message.ack() # await message.ack()
return False return False
# Create a task for each file download # Create a task for each file download
+73
View File
@@ -0,0 +1,73 @@
import logging
import aio_pika
import asyncio
import os
from pathlib import Path
from aio_pika.abc import AbstractRobustChannel
from aio_pika import Message, DeliveryMode
from amqp.router.router_base import AMQ_REPLY_TO_EXCHANGE
async def publish_file_to_rabbitmq(
channel: AbstractRobustChannel,
file_path: Path,
max_chunk_size: int = 2**20, # 1MB default chunk size
exchange_name: str = AMQ_REPLY_TO_EXCHANGE,
content_type: str = 'application/octet-stream', # Default content type
delivery_mode: DeliveryMode = DeliveryMode.PERSISTENT,
) -> str:
"""
Reads a file from the file system and publishes its content to a RabbitMQ exchange
in chunks. It declares a queue with a random name, binds it to the specified
exchange, and uses the queue name as the routing key.
Args:
channel: The aio_pika RobustChannel to use for communication with RabbitMQ.
file_path: The path to the file to publish.
max_chunk_size: The maximum size of each chunk (in bytes).
exchange_name: The name of the RabbitMQ exchange to publish to.
content_type: The content type of the file data.
delivery_mode: The delivery mode (e.g., PERSISTENT or TRANSIENT).
Returns:
The name of the randomly generated queue where the file content was published.
"""
if not file_path.is_file():
raise FileNotFoundError(f"File not found: {file_path}")
_file_size = os.path.getsize(file_path)
_exchange = await channel.get_exchange(name=exchange_name, ensure=True)
_queue = await channel.declare_queue(exclusive=True) # Declare a unique, exclusive queue
_queue_name = _queue.name
await _queue.bind(
exchange=exchange_name,
routing_key=_queue_name, # Use queue name as routing key
)
logging.info(f"Publishing file: {file_path} (size: {_file_size}) to exchg: {exchange_name}, q: {_queue_name}")
with open(file_path, 'rb') as f:
_offset = 0
while True:
_chunk = f.read(max_chunk_size)
if not _chunk:
break # End of file
message = Message(
body=_chunk,
content_type=content_type,
delivery_mode=delivery_mode,
headers={
'file_name': file_path.name,
'offset': _offset,
'total_size': _file_size,
'chunk_size': len(_chunk),
'last_chunk': max_chunk_size + _offset >= _file_size
}
)
await _exchange.publish(message, routing_key=_queue_name)
_offset += len(_chunk)
logging.debug(f"Published chunk {_offset}/{_file_size} to {exchange_name}:{_queue_name}")
print(f"File {file_path} published to RabbitMQ exchange {exchange_name}, queue {_queue_name}")
return _queue_name
@@ -10,6 +10,40 @@ from python_multipart.multipart import Field, File
from amqp.model.model import DataMessage from amqp.model.model import DataMessage
def process_form_data(json_input) -> dict:
"""
Processes the input JSON to extract form data. The JSON has two top entries: 'files' and 'form'.
This is part of the file stream implementation in AMQ adapter to stream large files in chunks.
'files' contains information about the AMQ queue from which read the file, while 'form' contains
additional non-file form values.
This function combines the file and values at the top level in the returned dictionary.
Args:
json_input: JSON string or dict containing the form and files data
Returns:
dict: Processed form data with files data overriding non-empty values
"""
# Load JSON if input is string
if isinstance(json_input, str):
data = json.loads(json_input)
else:
data = json_input
result = {}
# Process form data - take first element of each list
for key, value in data['form'].items():
if value: # Only process if list is not empty
result[key] = value[0]
# Override with files data where available
for key, value in data['files'].items():
if value: # Only override if files data is non-empty
result[key] = value[0]
return result
class CleverMultiPartParser: class CleverMultiPartParser:
def __init__(self, message: DataMessage): def __init__(self, message: DataMessage):
self.form_data = {} self.form_data = {}
@@ -33,7 +67,7 @@ class CleverMultiPartParser:
self.form_data = json.loads(message.body()) self.form_data = json.loads(message.body())
self.is_json = True self.is_json = True
if self.form_data['files'] and self.form_data['form']: if self.form_data['files'] and self.form_data['form']:
self.form_data = self.process_form_data(self.form_data) self.form_data = process_form_data(self.form_data)
except Exception as jsonerror: except Exception as jsonerror:
logging.error(f"Error parsing JSON: {jsonerror}") logging.error(f"Error parsing JSON: {jsonerror}")
self.is_valid = False self.is_valid = False
@@ -50,33 +84,3 @@ class CleverMultiPartParser:
self.form_data[file.field_name.decode('utf-8')] = \ self.form_data[file.field_name.decode('utf-8')] = \
UploadFile(file.file_object, size=file.size, filename=file.file_name) UploadFile(file.file_object, size=file.size, filename=file.file_name)
print(self.form_data) print(self.form_data)
def process_form_data(self, json_input):
"""
Processes the input JSON to extract form data, with files data overriding where available.
Args:
json_input: JSON string or dict containing the form and files data
Returns:
dict: Processed form data with files data overriding non-empty values
"""
# Load JSON if input is string
if isinstance(json_input, str):
data = json.loads(json_input)
else:
data = json_input
result = {}
# Process form data - take first element of each list
for key, value in data['form'].items():
if value: # Only process if list is not empty
result[key] = value[0]
# Override with files data where available
for key, value in data['files'].items():
if value: # Only override if files data is non-empty
result[key] = value[0]
return result
-23
View File
@@ -142,29 +142,6 @@ def _is_int_string(s: str) -> bool:
return False return False
def serialize_to_json(obj: BaseModel) -> str:
"""
Serializes an BaseModel object to a JSON string. Handles UUIDs and datetimes
natively using the default JSON encoder with some help for non-standard types.
Args:
obj: The R2RSerializable object to serialize.
Returns:
A JSON string representation of the object.
"""
def default_serializer(o):
if isinstance(o, uuid.UUID):
return str(o)
elif isinstance(o, datetime):
return o.isoformat()
elif isinstance(o, BaseModel):
return o.model_dump()
raise TypeError(f"Object of type {o.__class__.__name__} is not JSON serializable")
return json.dumps(obj.model_dump(), default=default_serializer)
def parse_url_query(url): def parse_url_query(url):
""" """
Parses a HTTP GET URL and returns (context_path, query_params_dict) Parses a HTTP GET URL and returns (context_path, query_params_dict)
+1 -1
View File
@@ -6,7 +6,7 @@ from amqp.adapter.trace_info_adapter import TraceInfoAdapter
from amqp.model.model import ServiceMessage from amqp.model.model import ServiceMessage
from amqp.model.service_message_type import ServiceMessageType from amqp.model.service_message_type import ServiceMessageType
from amqp.model.snowflake_id import SnowflakeId from amqp.model.snowflake_id import SnowflakeId
from amqp.router.serializer import parse_map_string, map_as_string from amqp.adapter.serializer import parse_map_string, map_as_string
RS = "|" RS = "|"
SPLIT_RS = r"\|" SPLIT_RS = r"\|"
-7
View File
@@ -1,9 +1,6 @@
import base64 import base64
import json import json
from enum import Enum from enum import Enum
from dataclasses import dataclass
from typing import Final
from typing import List, Dict from typing import List, Dict
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -166,10 +163,6 @@ class DataMessage:
return base64.b64decode(self.base64_body) return base64.b64decode(self.base64_body)
# Ensure backward compatibility
AMQMessage = DataMessage
class MultipartDataMessage(DataMessage): class MultipartDataMessage(DataMessage):
def __init__(self, message: DataMessage, extra_data: dict): def __init__(self, message: DataMessage, extra_data: dict):
super().__init__(message.magic, message.id, super().__init__(message.magic, message.id,
+8 -7
View File
@@ -79,29 +79,30 @@ class RabbitMQClient:
exclusive=False, exclusive=False,
auto_delete=False, auto_delete=False,
arguments=queue_args) arguments=queue_args)
exchange_name: str = route.exchange if route.exchange else AMQ_RPC_EXCHANGE _exchange_name: str = route.exchange if route.exchange else AMQ_RPC_EXCHANGE
exchange: AbstractRobustExchange = await self.channel.declare_exchange( exchange: AbstractRobustExchange = await self.channel.declare_exchange(
name=exchange_name, type=ExchangeType.topic name=_exchange_name, type=ExchangeType.topic
) )
await queue.bind(exchange, route.key) await queue.bind(exchange, route.key)
await queue.consume(callback, no_ack=False) _c_tag = await queue.consume(callback, no_ack=False)
# Register the route with the router and publish its detail to other adapters # Register the route with the router and publish its detail to other adapters
await self.router.register_route( await self.router.register_route(
AMQRoute( AMQRoute(
route.component_name, exchange_name, queue_name, route.component_name, _exchange_name, queue_name,
sanitize_as_rabbitmq_name(route.key), sanitize_as_rabbitmq_name(route.key),
route.timeout, route.valid_until route.timeout, route.valid_until
) )
) )
logging.info(" [*] AMQ connection initialized for route: %s, listens on: %s/%s", logging.info(" [%s] AMQ connection initialized for route: %s, listens on: %s/%s",
route, exchange, queue_name) _c_tag, route, exchange, queue_name)
except Exception as err: except Exception as err:
logging.error(" [E] Callback registration INCOMPLETE, %s", err) logging.error(" [E] Callback registration INCOMPLETE, %s", err)
else: else:
logging.warning(" [W] Channel is null, cannot register routes.") logging.warning(" [W] Channel is null, cannot register routes.")
async def register_data_reply_callback(self, rpc_callback): async def register_data_reply_callback(self, rpc_callback):
await self.reply_to_queue.consume(callback=rpc_callback, no_ack=False) _c_tag = await self.reply_to_queue.consume(callback=rpc_callback, no_ack=False)
logging.info(" [%s] RabbitMQClient register data reply callback on queue: %s", _c_tag,self.reply_to_queue.name)
async def setup_amq_channel(self, loop): async def setup_amq_channel(self, loop):
""" """
+5 -5
View File
@@ -36,13 +36,13 @@ class RouterConsumer(RouterProducer):
if self.is_open(_channel): if self.is_open(_channel):
try: try:
# 1. declare a Queue to receive the RoutingData requests # 1. declare a Queue to receive the RoutingData requests
cm_request_queue: str = self.amq_configuration.amq_adapter.adapter_prefix() + CM_C_REQ_QUEUE _cm_request_queue: str = self.amq_configuration.amq_adapter.adapter_prefix() + CM_C_REQ_QUEUE
queue: AbstractRobustQueue = await self.channel.declare_queue(name=cm_request_queue, durable=True, exclusive=False, auto_delete=False) _queue: AbstractRobustQueue = await self.channel.declare_queue(name=_cm_request_queue, durable=True, exclusive=False, auto_delete=False)
# 1a. bind queue to CM_REQUEST_EXCHANGE w/o any conditions (empty routing key) # 1a. bind queue to CM_REQUEST_EXCHANGE w/o any conditions (empty routing key)
await queue.bind(exchange=CM_REQUEST_EXCHANGE, routing_key="") await _queue.bind(exchange=CM_REQUEST_EXCHANGE, routing_key="")
# 1b. Service adapter mode - listens on Request queue and replies to Response queue # 1b. Service adapter mode - listens on Request queue and replies to Response queue
logging.info(" [*] RouterConsumer listens for RouteDataRequests on: %s", cm_request_queue) _c_tag = await _queue.consume(callback=self.handle_routing_data_request_message, no_ack=False)
await queue.consume(callback=self.handle_routing_data_request_message, no_ack=False) logging.info(" [%s] RouterConsumer listens for RouteDataRequests on: %s", _c_tag, _cm_request_queue)
except Exception as ioe: except Exception as ioe:
logging.error(" [E] RouterConsumer FATAL: cannot initialize, %s", ioe) logging.error(" [E] RouterConsumer FATAL: cannot initialize, %s", ioe)
else: else:
+7 -7
View File
@@ -65,16 +65,16 @@ class RouterProducer(RouterBase):
name=CM_RESPONSE_EXCHANGE, type=ExchangeType.fanout name=CM_RESPONSE_EXCHANGE, type=ExchangeType.fanout
) )
# 2a. declare a Response queue for Routing Data replies from Services # 2a. declare a Response queue for Routing Data replies from Services
cm_response_queue: str = self.get_response_queue_name() _cm_response_queue: str = self.get_response_queue_name()
response_queue: AbstractRobustQueue = await self.channel.declare_queue( _response_queue: AbstractRobustQueue = await self.channel.declare_queue(
name=cm_response_queue, durable=True, exclusive=False, auto_delete=False, name=_cm_response_queue, durable=True, exclusive=False, auto_delete=False,
arguments={} arguments={}
) )
# 2b. bind queue to CM_RESPONSE_EXCHANGE w/o any conditions (empty routing key) # 2b. bind queue to CM_RESPONSE_EXCHANGE w/o any conditions (empty routing key)
await response_queue.bind(exchange=CM_RESPONSE_EXCHANGE, routing_key="#") await _response_queue.bind(exchange=CM_RESPONSE_EXCHANGE, routing_key="#")
# 2c. listen for incoming RoutingData messages # 2c. listen for incoming RoutingData messages
await response_queue.consume(callback=self.handle_routing_data_message, no_ack=False) _c_tag = await _response_queue.consume(callback=self.handle_routing_data_message, no_ack=False)
logging.info(" [*] Routing master listens for Route updates on %s", cm_response_queue) logging.info(" [%s] Routing master listens for Route updates on %s", _c_tag, _cm_response_queue)
except Exception as ioe: except Exception as ioe:
logging.error(" [E] RouterProducer FATAL: cannot initialize, %s", ioe) logging.error(" [E] RouterProducer FATAL: cannot initialize, %s", ioe)
@@ -92,7 +92,7 @@ class RouterProducer(RouterBase):
delivery.getEnvelope().getDeliveryTag(), delivery.getEnvelope().getDeliveryTag(),
delivery.getEnvelope().toString(), str(delivery.getBody())) delivery.getEnvelope().toString(), str(delivery.getBody()))
""" """
# ACK message now so that it is not being redelivered logging.info(" [%s] ACK message now so that it is not being redelivered", message.delivery_tag)
await message.ack(multiple=False) await message.ack(multiple=False)
cm_message: ServiceMessage = self.service_message_factory.from_bytes(message.body) cm_message: ServiceMessage = self.service_message_factory.from_bytes(message.body)
# ignore the message if it comes from ourselves (even if from different instance) # ignore the message if it comes from ourselves (even if from different instance)
+15 -8
View File
@@ -18,7 +18,7 @@ from opentelemetry.trace import Tracer, TraceState
from amqp.adapter.data_message_factory import DataMessageFactory from amqp.adapter.data_message_factory import DataMessageFactory
from amqp.adapter.data_response_factory import DataResponseFactory from amqp.adapter.data_response_factory import DataResponseFactory
from amqp.adapter.cleverthis_service_adapter import CleverThisServiceAdapter from amqp.adapter.cleverthis_service_adapter import CleverThisServiceAdapter, AMQMessage
from amqp.adapter.file_downloader import on_message from amqp.adapter.file_downloader import on_message
from amqp.adapter.service_message_factory import ServiceMessageFactory from amqp.adapter.service_message_factory import ServiceMessageFactory
from amqp.config.amq_configuration import AMQConfiguration from amqp.config.amq_configuration import AMQConfiguration
@@ -26,7 +26,7 @@ from amqp.model.model import DataResponse, DataMessage, MultipartDataMessage, Sc
from amqp.model.service_message_type import ServiceMessageType from amqp.model.service_message_type import ServiceMessageType
from amqp.rabbitmq.rabbit_mq_client import RabbitMQClient from amqp.rabbitmq.rabbit_mq_client import RabbitMQClient
from amqp.router.router_base import ANY_RECIPIENT from amqp.router.router_base import ANY_RECIPIENT
from amqp.router.serializer import map_as_string from amqp.adapter.serializer import map_as_string
# Track the number of messages currently being processed # Track the number of messages currently being processed
current_parallel_executions = 0 current_parallel_executions = 0
@@ -178,11 +178,15 @@ class DataMessageHandler:
last_data_message_time = time.time() last_data_message_time = time.time()
# Call the async on_message method. This shall also ACK the message after processing. # Call the async on_message method. This shall also ACK the message after processing.
response: DataResponse = asyncio.run(self.service_adapter.on_message(amq_message)) response: DataResponse = asyncio.run(
self.service_adapter.on_message(AMQMessage(amq_message, self.rabbit_mq_client.channel))
)
try: try:
logging.info(" [*] ######[%s] AMQ Message Reply, To=%s, msgId=%s", logging.info(" [*] ######[%s] AMQ Message Reply.1, To=%s, msgId=%s",
message.delivery_tag, message.reply_to, response.id) message.delivery_tag, message.reply_to, response.id)
asyncio.run_coroutine_threadsafe(self.send_reply(message.reply_to, response), loop=loop) future: Future = asyncio.run_coroutine_threadsafe(self.send_reply(message.reply_to, response), loop=loop)
logging.info(" [*] ######[%s] AMQ Message Reply.2, To=%s, future=%s, done=%s",
message.delivery_tag, message.reply_to, future, future.done())
except Exception as e: except Exception as e:
logging.info(f"[E] Error processing message: {e}") logging.info(f"[E] Error processing message: {e}")
tb = traceback.extract_tb(sys.exc_info()[2])[-1] tb = traceback.extract_tb(sys.exc_info()[2])[-1]
@@ -236,8 +240,9 @@ class DataMessageHandler:
amq_message = MultipartDataMessage(amq_message, extra_data) amq_message = MultipartDataMessage(amq_message, extra_data)
if amq_message is not None: if amq_message is not None:
logging.info(" [*] ######[%s] AMQ Message received, ctag=%s, msgId=%s, traceInfo=%s", logging.info(" [*] ######[%s] AMQ Message received, ctag=%s, msgId=%s, %s:%s, traceInfo=%s",
delivery_tag, message.consumer_tag, amq_message.id, map_as_string(amq_message.trace_info)) delivery_tag, message.consumer_tag, amq_message.id,
amq_message.method, amq_message.path, map_as_string(amq_message.trace_info))
propagator = TraceContextTextMapPropagator() propagator = TraceContextTextMapPropagator()
context = propagator.extract(carrier=amq_message.trace_info, context=trace.context_api.get_current()) context = propagator.extract(carrier=amq_message.trace_info, context=trace.context_api.get_current())
@@ -292,10 +297,12 @@ class DataMessageHandler:
content_type=response.content_type[0] content_type=response.content_type[0]
if isinstance(response.content_type, list) else response.content_type if isinstance(response.content_type, list) else response.content_type
) )
await self.reply_to_exchange.publish( logging.info(" [*] ###### Sending Reply.1 To=%s, msgId=%s", reply_to, response.id)
_res = await self.reply_to_exchange.publish(
message=pika_message, message=pika_message,
routing_key=reply_to routing_key=reply_to
) )
logging.info(" [*] ###### Sending Reply.2 To=%s, res=%s", reply_to, _res)
async def reply_received_callback(self, message: AbstractIncomingMessage): async def reply_received_callback(self, message: AbstractIncomingMessage):
""" """
+9 -8
View File
@@ -20,18 +20,18 @@ from schemas.user_schema import UserSchema
# AMQP specific imports # AMQP specific imports
from amqp.adapter.data_response_factory import AMQResponseFactory from amqp.adapter.data_response_factory import AMQResponseFactory
from amqp.adapter.cleverthis_service_adapter import CleverThisServiceAdapter, call_cleverthis_get_api, \ from amqp.adapter.cleverthis_service_adapter import CleverThisServiceAdapter, \
call_cleverthis_post_put_api, parse_get_url parse_get_url, call_cleverthis_api, AMQMessage
from amqp.service.multipart_parser import CleverMultiPartParser from amqp.adapter.multipart_parser import CleverMultiPartParser
from amqp.config.amq_configuration import AMQConfiguration from amqp.config.amq_configuration import AMQConfiguration
from amqp.model.model import AMQMessage, AMQResponse from amqp.model.model import AMQResponse
from amqp.service.amq_service import AMQService from amqp.service.amq_service import AMQService
# Suffix for the method name with serialization wrapper, prefer use this one over the orig endpoint # Suffix for the method name with serialization wrapper, prefer use this one over the orig endpoint
PREFERRED_SUFFIX = '_json' PREFERRED_SUFFIX = '_json'
def clone_and_adapt_route(original_route: APIRoute) -> APIRoute: def clone_and_adapt_route(original_route: APIRoute) -> APIRoute:
""" """
Creates a clone of an APIRoute. It checks if there's preferred endpoint with json serialization. Creates a clone of an APIRoute. It checks if there's preferred endpoint with json serialization.
@@ -43,7 +43,8 @@ def clone_and_adapt_route(original_route: APIRoute) -> APIRoute:
getattr(original_route.endpoint, '__module__'), original_route.endpoint.__name__ + PREFERRED_SUFFIX getattr(original_route.endpoint, '__module__'), original_route.endpoint.__name__ + PREFERRED_SUFFIX
) )
# Need to adapt also the path regex to ensure it matches the path in different API versions # Need to adapt also the path regex to ensure it matches the path in different API versions
_new_regex_string = '^.*' + original_route.path_regex.pattern.lstrip('^') # Ensures '^' is only at the start # Ensures the leading '^' is followed by any match and there's no '/' before final '$'
_new_regex_string = '^.*' + original_route.path_regex.pattern.lstrip('^').rstrip('/$') +'$'
_new_route: APIRoute = APIRoute( _new_route: APIRoute = APIRoute(
path=original_route.path, path=original_route.path,
endpoint=_preferred_endpoint if _preferred_endpoint else original_route.endpoint, endpoint=_preferred_endpoint if _preferred_endpoint else original_route.endpoint,
@@ -189,7 +190,7 @@ class CleverSwarmAmqpAdapter(CleverThisServiceAdapter):
args.update(match.groupdict()) args.update(match.groupdict())
args['authenticated_user'] = auth_user args['authenticated_user'] = auth_user
# If the path matches, call the corresponding function # If the path matches, call the corresponding function
return await call_cleverthis_post_put_api( return await call_cleverthis_api(
message, message,
args, args,
api_method=route.endpoint, api_method=route.endpoint,
@@ -231,7 +232,7 @@ class CleverSwarmAmqpAdapter(CleverThisServiceAdapter):
) or parser.form_data[body_param.name] ) or parser.form_data[body_param.name]
return await call_cleverthis_post_put_api( return await call_cleverthis_api(
message, message,
parser.form_data, parser.form_data,
api_method=route.endpoint, api_method=route.endpoint,
+1 -1
View File
@@ -8,7 +8,7 @@ packages = ["amqp","amqp.adapter","amqp.config","amqp.model","amqp.rabbitmq","am
[project] [project]
name = "amq_adapter" name = "amq_adapter"
version = "0.2.14" version = "0.2.16"
description = "The Python implementation of the AMQ Adaptor for CleverMicro" description = "The Python implementation of the AMQ Adaptor for CleverMicro"
readme = "README.md" readme = "README.md"
license = { text = "Apache License 2.0" } license = { text = "Apache License 2.0" }