CleverSwarm API invocation example

This commit is contained in:
Stanislav Hejny
2025-02-25 20:35:20 +00:00
parent 286a4986b3
commit eb26eec767
36 changed files with 1269 additions and 126 deletions
-7
View File
@@ -11,17 +11,10 @@ COPY ./requirements.txt .
# Install the Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
run pip install "fastapi[standard]"
# Copy the application code into the container
COPY ./.env_secrets .
COPY ./amqp ./amqp
COPY ./container-data ./container-data
# Expose the port that the FastAPI app will run on
EXPOSE 8080
# Set the command to run the FastAPI app
CMD ["python", "amqp/service/amq_service.py"]
#CMD ["fastapi", "run", "amqp/service/amq_service.py", "--host", "0.0.0.0", "--port", "8080"]
#CMD ["sleep", "300"]
+44 -1
View File
@@ -13,7 +13,7 @@ from amqp.router.utils import sanitize_as_rabbitmq_name
class AMQMessageFactory:
SNOWFLAKE_ID_BITS = 80
SEQUENCE_BITS = 12
MACHINE_ID_BITS = 26
MACHINE_ID_BITS = 24
TIMESTAMP_BITS = SNOWFLAKE_ID_BITS - SEQUENCE_BITS - MACHINE_ID_BITS
MACHINE_ID_MASK = (1 << MACHINE_ID_BITS) - 1
@@ -30,11 +30,20 @@ class AMQMessageFactory:
@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 = AMQMessageFactory(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:
@@ -64,6 +73,15 @@ class AMQMessageFactory:
headers: Dict[str, List[str]],
message,
) -> AMQMessage:
"""
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: AMQMessage object
"""
serializable_headers = headers.copy() if headers else {}
payload = base64.b64encode(message.encode('utf-8') if isinstance(message, str) else message)
trace_info = {}
@@ -80,10 +98,20 @@ class AMQMessageFactory:
@staticmethod
def amq_message_routing_key(message: AMQMessage) -> str:
"""
Constructs the message specific routing key.
:param message: AMQMessage 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] >> (AMQMessageFactory.SEQUENCE_BITS + AMQMessageFactory.MACHINE_ID_BITS)
high = bits[1] << (64 - AMQMessageFactory.SEQUENCE_BITS - AMQMessageFactory.MACHINE_ID_BITS)
@@ -91,6 +119,11 @@ class AMQMessageFactory:
@staticmethod
def to_string(message: AMQMessage) -> str:
"""
Human-readable representation.
:param message: AMQMessage input
:return: as string
"""
return (
f"AMQMessage{{"
f"id={message.id}, "
@@ -105,6 +138,11 @@ class AMQMessageFactory:
@staticmethod
def serialize(message: AMQMessage) -> bytes:
"""
Converts AMQMessage 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="
@@ -122,6 +160,11 @@ class AMQMessageFactory:
@staticmethod
def from_bytes(input_bytes: bytes) -> AMQMessage:
"""
Builds the AMQMessage from its serialized (byte array) form.
:param input_bytes: serialized message
:return: AMQMessage instance
"""
prolog_len = (input_bytes[0] << 8) + input_bytes[1]
metadata = input_bytes[2:prolog_len + 2].decode()
+24
View File
@@ -11,12 +11,26 @@ class AMQResponseFactory:
@staticmethod
def create_async_response_message(_id: SnowflakeId) -> AMQResponse:
"""
Helper method to create standard OK response.
:param _id: ID
:return: Response message
"""
return AMQResponse(
str(_id), 200, "application/text", "OK".encode("utf-8"), "", "", {}
)
@staticmethod
def of(id: SnowflakeId, response_code: int, content_type: str, body: bytes, trace_info: dict[str,str]) -> AMQResponse:
"""
Create the AMQResponse message supplying all key values
:param id: SnowflakeID
:param response_code: HTTP response code
:param content_type: MIME content type
:param body: payload
:param trace_info: Jaeger trace info (id)
:return: AMQResponseMessage object
"""
return AMQResponse(
id=id.as_string(),
response_code=response_code,
@@ -29,6 +43,11 @@ class AMQResponseFactory:
@staticmethod
def serialize(response: AMQResponse) -> bytes:
"""
Serialize the message to byte array (to be sent over RabbitMQ)
:param response: Object to serialize
:return: byte array as serialized message
"""
id_bytes = response.id.encode('utf-8')
prolog = (
f"|r={response.response_code}|c={response.content_type}|e={response.error}|f={response.error_cause}|t={{{map_as_string(response.trace_info)}}}|b="
@@ -46,6 +65,11 @@ class AMQResponseFactory:
@staticmethod
def from_bytes(input_bytes: bytes) -> AMQResponse:
"""
Buil;ds the AMQResponse object from its serialized form
:param input_bytes: byte array - serialized response
:return: AMQResponse object
"""
prolog_len = (input_bytes[0] << 8) + input_bytes[1]
metadata = input_bytes[2:prolog_len + 2].decode()
+10
View File
@@ -17,6 +17,11 @@ class AMQRouteFactory:
@staticmethod
def from_string(data):
"""
Builds the AMQRoute from its string form.
:param data: route string
:return: AMQRoute object
"""
try:
keys = data.split(RS)
if len(keys) >= 5:
@@ -55,6 +60,11 @@ class AMQRouteFactory:
@staticmethod
def validate(route_str):
"""
Validates the route string.
:param route_str: route string
:return: boolean validity indicator
"""
if route_str is not None:
return route_str.count(":") > 4
return False
+336 -20
View File
@@ -1,10 +1,200 @@
import logging
from typing import Dict, List
from aiohttp import ClientSession, ClientResponse
import re
from typing import Dict, List, Tuple, Callable, Coroutine, AnyStr, Any
import python_multipart
from aiohttp import ClientSession, ClientResponse
from fastapi import HTTPException, UploadFile
from python_multipart.multipart import Field, File
from amqp.adapter.amq_response_factory import AMQResponseFactory
from amqp.config.amq_configuration import AMQConfiguration
from amqp.model.model import AMQMessage, AMQResponse
import rest.v0.endpoints.benchmark as benchmark
import rest.v0.endpoints.jobs as jobs
import rest.v0.endpoints.unstructured as unstructured
from core.deps import get_current_user
from schemas.user_schema import UserSchema
import json
from urllib.parse import parse_qs
from xml.etree import ElementTree
import io
class CleverMultiPartParser:
def __init__(self, message: AMQMessage):
self.form_data = {}
# Convert each list of strings to a single string joined by newline, then to bytes
headers = {key: "\n".join(value).encode("utf-8") for key, value in message.headers.items()}
python_multipart.parse_form(headers, io.BytesIO(message.body()), self.on_field, self.on_file)
def on_field(self, field: Field):
print(field)
self.form_data[field.field_name.decode('utf-8')] = field.value
print(self.form_data)
def on_file(self, file: File):
print(file)
self.form_data[file.field_name.decode('utf-8')] = \
UploadFile(file.file_object, size=file.size, filename=file.file_name)
print(self.form_data)
def parse_request_body(message: AMQMessage):
"""
Parses the HTTP POST request body based on the MIME type.
Args:
body (str): The raw request body as a string.
content_type (str): The Content-Type header of the request.
Returns:
dict: Parsed data as a dictionary.
"""
content_type = message.headers.get('Content-Type', '')
if not content_type:
raise ValueError("Content-Type header is required")
# Parse JSON
if content_type == "application/json":
try:
return json.loads(message.body())
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON: {e}")
# Parse URL-encoded form data
elif content_type == "application/x-www-form-urlencoded":
try:
return {k: v[0] if len(v) == 1 else v for k, v in parse_qs(message.body()).items()}
except Exception as e:
raise ValueError(f"Invalid URL-encoded form data: {e}")
# Parse multipart/form-data
elif content_type.startswith("multipart/form-data"):
try:
parser: CleverMultiPartParser = CleverMultiPartParser(message)
return parser.form_data
except Exception as e:
raise ValueError(f"Invalid multipart/form-data: {e}")
# Parse plain text
elif content_type == "text/plain":
return {"text": message.body()}
# Parse XML
elif content_type == "application/xml":
try:
root = ElementTree.fromstring(message.body())
return {elem.tag: elem.text for elem in root}
except ElementTree.ParseError as e:
raise ValueError(f"Invalid XML: {e}")
else:
raise ValueError(f"Unsupported Content-Type: {content_type}")
BENCHMARK = '/benchmark'
JOBS = '/jobs/'
UNSTRUCTURED = '/unstructured'
GET_BENCHMARK_KG = r".*/benchmark/(?P<request_id>[^\?]+)\?index=(?P<index>\d+)"
GET_JOB_STATUS = r".*/jobs/(?P<request_id>.*)"
GET_KG = r".*/unstructured/(?P<request_id>.*)"
DUMMY = r"(?P<request_id>.*)"
# ============= POST Helper function =============
async def call_cleverswarm_post_put_api(
message: AMQMessage,
args: Any,
api_method: Callable[[Any, UserSchema], Coroutine],
success_code: int,
authenticated_user: UserSchema
) -> AMQResponse:
try:
coroutine_call: Coroutine = api_method(args, authenticated_user)
result: str = await coroutine_call
return AMQResponseFactory.of(
message.id, success_code, message.headers.get("Content-Type", "application/json"),
result.encode('utf-8'), message.trace_info
)
except HTTPException as http_error:
return AMQResponseFactory.of(message.id, http_error.status_code, "text/plain",
str(http_error.detail).encode('utf-8'), message.trace_info)
except Exception as error:
return AMQResponseFactory.of(message.id, 500, "text/plain", str(error).encode('utf-8'), message.trace_info)
#
# Following methods are wrappers to actual CleverSwarm endpoints, so I can refer to the CleverSwarm method and
# pass it as input parameter using general Callable[[Any, UserSchema], str] type
#
async def cleverswarm_create_benchmark_job(args: Any, authenticated_user: UserSchema) -> str:
return str(await benchmark.create_benchmark_job(**args, authenticated_user=authenticated_user))
async def cleverswarm_create_extract_with_ontology(args: Any, authenticated_user: UserSchema) -> str:
return str(await unstructured.create_extract_with_ontology(**args, authenticated_user=authenticated_user))
async def cleverswarm_create_extract_with_wildcards(args: Any, authenticated_user: UserSchema) -> str:
return str(await unstructured.create_extract_with_ontology(**args, authenticated_user=authenticated_user))
async def cleverswarm_update_benchmark_job(args: Any, authenticated_user: UserSchema) -> str:
return str(await benchmark.update_benchmark_job(**args, authenticated_user=authenticated_user))
async def cleverswarm_retry_job(args: Any, authenticated_user: UserSchema) -> str:
return str(await jobs.retry_job(args[0], authenticated_user=authenticated_user))
async def cleverswarm_delete_job(args: Any, authenticated_user: UserSchema) -> str:
return str(await jobs.delete_job(args[0], authenticated_user=authenticated_user))
# ============= GET Helper function =============
async def call_cleverswarm_get_api(
message: AMQMessage,
pattern: str,
api_method: Callable[[Tuple[AnyStr | Any, ...], UserSchema], Coroutine],
authenticated_user: UserSchema
) -> AMQResponse:
try:
match = re.search(pattern, message.path)
if match:
crt: Coroutine = api_method(match.groups(), authenticated_user)
result: str = await crt
return AMQResponseFactory.of(message.id, 200, "text/plain", result.encode('utf-8'), message.trace_info)
else:
_msg = f"Unexpected URL: {message.path}, expected pattern: {pattern}"
return AMQResponseFactory.of(message.id, 404, "text/plain", _msg.encode('utf-8'), message.trace_info)
except HTTPException as http_error:
return AMQResponseFactory.of(message.id, http_error.status_code, "text/plain",
str(http_error.detail).encode('utf-8'), message.trace_info)
except Exception as error:
return AMQResponseFactory.of(message.id, 500, "text/plain", str(error).encode('utf-8'), message.trace_info)
async def cleverswarm_get_benchmark_kg(args: Tuple[AnyStr | Any, ...], authenticated_user: UserSchema) -> str:
return str(await benchmark.get_benchmark_kg(args[0], args[1], authenticated_user=authenticated_user))
async def cleverswarm_get_job_status(args: Tuple[AnyStr | Any, ...], authenticated_user: UserSchema) -> str:
return str(await jobs.get_job_status(args[0], authenticated_user=authenticated_user))
async def cleverswarm_jobs_get_requests(args: Tuple[AnyStr | Any, ...],
authenticated_user: UserSchema) -> str:
return str(await jobs.get_requests(authenticated_user=authenticated_user))
async def cleverswarm_get_kg(args: Tuple[AnyStr | Any, ...], authenticated_user: UserSchema) -> str:
return str(await unstructured.get_kg(args[0], authenticated_user=authenticated_user))
class CleverSwarmAdapter:
def __init__(self, amq_configuration: AMQConfiguration, session: ClientSession = None):
@@ -13,55 +203,181 @@ class CleverSwarmAdapter:
self.service_host = self.amq_configuration.dispatch.service_host
self.service_port = self.amq_configuration.dispatch.service_port
def on_message(self, amq_message) -> AMQResponse:
pass
async def handle(self, message: AMQMessage) -> ClientResponse:
async def on_message(self, message) -> AMQResponse:
DEBUG_NO_AUTH = 1
_auth: str = message.headers.get('Authorization', '')
_auth_user: UserSchema | None = None
if 'Bearer' in _auth:
try:
token = _auth.split(' ')[1]
_auth_user = await get_current_user(token)
except Exception as error:
logging.error(f"Error getting user: {error}")
if _auth_user or DEBUG_NO_AUTH == 1:
method = message.method.lower()
if method == 'get':
return await self.get(message)
return await self.get(message, _auth_user)
elif method == 'put':
return await self.put(message)
return await self.put(message, _auth_user)
elif method == 'post':
return await self.post(message)
return await self.post(message, _auth_user)
elif method == 'head':
return await self.head(message)
return await self.head(message, _auth_user)
elif method == 'delete':
return await self.delete(message)
return await self.delete(message, _auth_user)
else:
raise ValueError(f"Unexpected HTTP method: {message.method}")
else:
return AMQResponseFactory.of(message.id, 401, "text/plain", b"Unauthorized", message.trace_info)
async def get(self, message: AMQMessage) -> ClientResponse:
# ==================================================================================================
# ===================== C l e v e r S w a r m A P I m e t h o d s ============================
# ==================================================================================================
async def get(self, message: AMQMessage, auth_user: UserSchema) -> AMQResponse:
"""
Handles the GET requests for the CleverSwarm API.
:param message: message from the AMQP that contains the GET request
:param auth_user: user authenticated by the API Gateway, as UserSchem
:return: AMQResponse to be sent back to the API Gateway via AMQP
"""
if BENCHMARK in message.path:
return await call_cleverswarm_get_api(
message,
GET_BENCHMARK_KG,
cleverswarm_get_benchmark_kg,
authenticated_user=auth_user
)
if JOBS in message.path:
return await call_cleverswarm_get_api(
message,
GET_JOB_STATUS,
cleverswarm_get_job_status,
authenticated_user=auth_user
)
if message.path.endswith('/jobs'):
return await call_cleverswarm_get_api(
message,
DUMMY,
cleverswarm_jobs_get_requests,
authenticated_user=auth_user
)
if UNSTRUCTURED in message.path:
return await call_cleverswarm_get_api(
message,
GET_KG,
cleverswarm_get_kg,
authenticated_user=auth_user
)
# unmatched path - try to invoke the service directly on this path
url = f"http://{self.service_host}:{self.service_port}{message.path}"
headers = {**message.headers, **message.trace_info}
logging.info(f" [*] SEND HEADERS out: {', '.join(f'{k}={v}' for k, v in headers.items())}, REQ={url}")
return await self.session.get(url, headers=headers)
if self.session:
_http_resp: ClientResponse = await self.session.get(url, headers=headers)
return AMQResponseFactory.of(message.id, _http_resp.status, _http_resp.content_type,
await _http_resp.read(),
message.trace_info)
return AMQResponseFactory.of(message.id, 404, "text/plain",
str("Destination location does not exists").encode('utf-8'), message.trace_info)
async def put(self, message: AMQMessage, auth_user: UserSchema) -> AMQResponse:
"""
Handles the PUT requests for the CleverSwarm API.
:param message: message from the AMQP that contains the PUT request
:param auth_user: user authenticated by the API Gateway, as UserSchem
:return: AMQResponse to be sent back to the API Gateway via AMQP
"""
parser: CleverMultiPartParser = CleverMultiPartParser(message)
if BENCHMARK in message.path:
return await call_cleverswarm_post_put_api(
message,
parser.form_data,
cleverswarm_create_benchmark_job,
201,
authenticated_user=auth_user
)
if JOBS in message.path:
return await call_cleverswarm_get_api(
message,
GET_JOB_STATUS,
cleverswarm_retry_job,
authenticated_user=auth_user
)
async def put(self, message: AMQMessage) -> ClientResponse:
return await self.handle_possible_form(
self.session.put(
f"http://{self.service_host}:{self.service_port}{message.path}",
data=message.body,
headers={**message.headers, **message.trace_info},
)
),
auth_user
)
async def post(self, message: AMQMessage, auth_user: UserSchema) -> AMQResponse:
"""
Handles the POST requests for the CleverSwarm API.
:param message: message from the AMQP that contains the POST request, including body and headers
:param auth_user: user authenticated by the API Gateway, as UserSchem
:return: AMQResponse to be sent back to the API Gateway via AMQP
"""
parser: CleverMultiPartParser = CleverMultiPartParser(message)
if BENCHMARK in message.path:
return await call_cleverswarm_post_put_api(
message,
parser.form_data,
cleverswarm_create_benchmark_job,
201,
authenticated_user=auth_user
)
if UNSTRUCTURED in message.path:
if 'with_ontology' in message.path:
return await call_cleverswarm_post_put_api(
message,
parser.form_data,
cleverswarm_create_extract_with_ontology,
200,
authenticated_user=auth_user
)
if 'with_wildcards' in message.path:
return await call_cleverswarm_post_put_api(
message,
parser.form_data,
cleverswarm_create_extract_with_wildcards,
200,
authenticated_user=auth_user
)
async def post(self, message: AMQMessage) -> ClientResponse:
return await self.handle_possible_form(
self.session.post(
f"http://{self.service_host}:{self.service_port}{message.path}",
data=message.body,
headers={**message.headers, **message.trace_info},
)
),
auth_user
)
async def handle_possible_form(self, request_coroutine) -> ClientResponse:
async def handle_possible_form(self, request_coroutine, auth_user: UserSchema) -> AMQResponse:
return await request_coroutine
async def head(self, message: AMQMessage) -> ClientResponse:
async def head(self, message: AMQMessage, auth_user: UserSchema) -> AMQResponse:
raise NotImplementedError
async def delete(self, message: AMQMessage) -> ClientResponse:
async def delete(self, message: AMQMessage, auth_user: UserSchema) -> AMQResponse:
parser: CleverMultiPartParser = CleverMultiPartParser(message)
if JOBS in message.path:
return await call_cleverswarm_get_api(
message,
GET_JOB_STATUS,
cleverswarm_retry_job,
authenticated_user=auth_user
)
raise NotImplementedError
def get_content_type(self, message_headers: Dict[str, List[str]]) -> str:
-15
View File
@@ -1,15 +0,0 @@
from dataclasses import dataclass
from concurrent.futures import Future
from aiohttp.web_response import Response
from opentelemetry.trace import Span
@dataclass
class ResponseHolder:
response: Future[Response]
tracing_span: Span
def __init__(self, response: Future[Response], span: Span):
self.response = response
self.tracing_span = span
-5
View File
@@ -1,5 +0,0 @@
class ServiceAdapter:
def __init__(self):
pass
+33 -2
View File
@@ -16,11 +16,18 @@ INVALID_MESSAGE = CleverMicroServiceMessage() # default values mean invalid mes
class CleverMicroServiceMessageFactory:
"""
build/serialize/deserialize CleverMicro Service Message class
"""
def __init__(self, name: str):
self.process_name = name
def to_string(self, message: CleverMicroServiceMessage) -> str:
"""
Convert CleverMicroServiceMessage to human-readable format
:param message: CleverMicroServiceMessage object
:return: as string
"""
if message is not None:
return (
f"CleverMicroServiceMessage{{id={message.id.as_string()}|type={self.format_message_type(message)}|"
@@ -30,6 +37,13 @@ class CleverMicroServiceMessageFactory:
return "null"
def of(self, message_type: ServiceMessageType, payload: str, recipient_name: str) -> CleverMicroServiceMessage:
"""
Builds CleverMicroServiceMessage object from relevant values.
:param message_type: enum Message type
:param payload: Message content, stored as-is
:param recipient_name: Recipient name (from the routing expression)
:return: CleverMicroServiceMessage object
"""
_id = AMQMessageFactory.get_instance(1).generate_snowflake_id()
return CleverMicroServiceMessage(
id=_id,
@@ -42,6 +56,13 @@ class CleverMicroServiceMessageFactory:
)
def as_base64(self, message_type: ServiceMessageType, payload: str, recipient_name: str) -> CleverMicroServiceMessage:
"""
Builds CleverMicroServiceMessage object from relevant values, stores content Base64 encoded.
:param message_type: enum Message type
:param payload: Message content, stored as Base64 string
:param recipient_name: Recipient name (from the routing expression)
:return: CleverMicroServiceMessage object
"""
_id = AMQMessageFactory.get_instance(1).generate_snowflake_id()
return CleverMicroServiceMessage(
id=_id,
@@ -54,6 +75,11 @@ class CleverMicroServiceMessageFactory:
)
def from_bytes(self, input_bytes: bytes) -> CleverMicroServiceMessage:
"""
Deserialize CleverMicroServiceMessage from byte array serialized form.
:param input_bytes: serialized CleverMicroServiceMessage
:return: CleverMicroServiceMessage object represented by the input.
"""
logging.info(f"CleverMicroServiceMessage >>> [{input_bytes.decode()}]")
input_str_array = input_bytes.decode().split(RS)
try:
@@ -89,6 +115,11 @@ class CleverMicroServiceMessageFactory:
return INVALID_MESSAGE
def serialize(self, message: CleverMicroServiceMessage) -> bytes:
"""
Serialize CleverMicroServiceMessage to byte array.
:param message: CleverMicroServiceMessage to serialize.
:return: serialized object.
"""
return (
f"{message.id.as_string()}{RS}{self.format_message_type(message)}{RS}{message.recipient_name}{RS}"
f"{message.reply_to}{RS}{map_as_string(message.trace_info)}{RS}"
@@ -98,7 +129,7 @@ class CleverMicroServiceMessageFactory:
def format_message_type(self, message: CleverMicroServiceMessage) -> str:
"""
Formats the Enum message type and ecorates it with base64 flag at highest bit.
Formats the Enum message type and decorates it with base64 flag at highest bit.
For compatibility with 0-based enum ordinals in Java subtract 1 from ordinal value.
:param message: Service message to send
:return: formatted message type
+1 -1
View File
@@ -53,7 +53,7 @@ class AMQMessageFactoryTest(TestCase):
_f = AMQMessageFactory(35)
_req = _f.create_request_message("PUT", "localhost", "/test/me", {"k1": ["v1"], "k2": ["v2"]}, "Quick brown fox")
_key = _f.amq_message_routing_key(_req)
self.assertEqual("localhost..test.me", _key, "Message routing key wrongly extracted from the message")
self.assertEqual("localhost.test.me", _key, "Message routing key wrongly extracted from the message")
def test_get_timestamp_from_id(self):
_f = AMQMessageFactory(35)
+10 -6
View File
@@ -1,6 +1,6 @@
from unittest import TestCase
from amqp.adapter.service_message_factory import CleverMicroServiceMessageFactory, RS
from amqp.adapter.service_message_factory import CleverMicroServiceMessageFactory, RS, INVALID_MESSAGE
from amqp.model.model import CleverMicroServiceMessage
from amqp.model.service_message_type import ServiceMessageType
@@ -11,13 +11,15 @@ class CleverMicroServiceMessageTest(TestCase):
_f: CleverMicroServiceMessageFactory = CleverMicroServiceMessageFactory('bumble-bee')
message: CleverMicroServiceMessage = _f.of(ServiceMessageType.ROUTING_DATA, "Duck", "Donald")
message_str = _f.to_string(message)
assert "|type=01|" in message_str
assert "|body=Duck" in message_str
self.assertTrue("|type=01|" in message_str)
self.assertTrue("|body=Duck" in message_str)
message: CleverMicroServiceMessage = _f.of(ServiceMessageType.ROUTING_DATA_REPLACE, "Duck", "Donald")
message_str = _f.to_string(message)
assert "|type=03|" in message_str
assert "|body=Duck" in message_str
assert "|replyTo=bumble-bee" in message_str
self.assertTrue("|type=03|" in message_str)
self.assertTrue("|body=Duck" in message_str)
self.assertTrue("|replyTo=bumble-bee" in message_str)
nullMsg = _f.to_string(None)
self.assertEqual("null", nullMsg)
def test_from(self):
_f: CleverMicroServiceMessageFactory = CleverMicroServiceMessageFactory("amqp-router")
@@ -29,6 +31,8 @@ class CleverMicroServiceMessageTest(TestCase):
assert deserialized.recipient_name == "Donald"
assert deserialized.message == "Duck"
assert deserialized.reply_to == "amqp-router"
invalidMsg = _f.from_bytes(b'wrong')
self.assertEqual(invalidMsg, INVALID_MESSAGE)
def test_base64(self):
_f: CleverMicroServiceMessageFactory = CleverMicroServiceMessageFactory("amqp-router")
+1 -1
View File
@@ -4,7 +4,7 @@ from typing import Dict
@dataclass
class TraceInfoAdapter:
"""
This class should set up the Open Telemetry tracing stack. To be done later.
TODO This class should set up the Open Telemetry tracing stack. To be done later.
"""
@staticmethod
+30 -5
View File
@@ -1,8 +1,15 @@
import configparser
import os
"""
Convert configuration from properties file to an object.
"""
class AMQAdapter:
"""
configuration with prefix 'cm.amq-adapter'
"""
PREFIX: str = 'cm.amq-adapter.'
@@ -23,8 +30,10 @@ class AMQAdapter:
return f"{self.service_name}-{self.generator_id}-"
class Dispatch:
"""
configuration with prefix 'cm.dispatch'
"""
PREFIX: str = 'cm.dispatch.'
@@ -52,6 +61,9 @@ class Dispatch:
class Backpressure:
"""
configuration with prefix 'cm.backpressure'
"""
PREFIX: str = 'cm.backpressure.'
@@ -68,15 +80,28 @@ class Backpressure:
self.time_window = config.getint('CleverMicro-AMQ', self.PREFIX + 'time-window', fallback=3000)
class AMQConfiguration:
DEFAULT_PROPERTIES_FILE = '/app/amqp/config/application.properties'
class AMQConfiguration:
"""
Top level configuration context holder / object
"""
def __init__(self):
config = configparser.ConfigParser()
# Read the application.properties file
if os.getenv('USER', 'linux') == 'stanhejny':
config.read('../config/application.properties.local')
if os.path.exists(DEFAULT_PROPERTIES_FILE):
config.read(DEFAULT_PROPERTIES_FILE)
else:
config.read('/app/amqp/config/application.properties')
config.read('../config/application.properties.local')
# Override values with environment variables
for section in config.sections():
for option in config.options(section):
# env_var = f"{section.upper()}_{option.upper()}"
if option in os.environ:
config.set(section, option, os.environ[option])
#
# Add individual config areas
#
+1 -1
View File
@@ -5,7 +5,7 @@
cm.amq-adapter.service-name=amq-adapter
cm.amq-adapter.is-router=false
cm.amq-adapter.generator-id=164
cm.amq-adapter.route-mapping=amq-clever-pybook::amq-clever-pybook:clever.pybook.localhost.#:5:0
cm.amq-adapter.route-mapping=amq-cleverswarm::amq-cleverswarm:cleverswarm.localhost.#:5:0
cm.dispatch.use-dlq=true
cm.dispatch.use-confirms=false
+1 -1
View File
@@ -5,7 +5,7 @@
cm.amq-adapter.service-name=amq-adapter
cm.amq-adapter.is-router=false
cm.amq-adapter.generator-id=211
cm.amq-adapter.route-mapping=amq-clever-pybook::amq-clever-pybook:clever.pybook.localhost.#:5:0
cm.amq-adapter.route-mapping=amq-cleverswarm::amq-cleverswarm:cleverswarm.localhost.#:5:0
cm.dispatch.use-dlq=true
cm.dispatch.use-confirms=false
+9
View File
@@ -10,6 +10,11 @@ from amqp.router.utils import sanitize_as_rabbitmq_name
RS = ":"
"""
Define the structure of messages used in Clever Microś
"""
@dataclass(frozen=True)
class AMQRoute:
"""
@@ -47,6 +52,10 @@ class AMQRoute:
@property
def primary_key(self):
"""
Build the unique identifier of the route
:return primary key
"""
return f"{self.component_name}{RS}{self.queue}{RS}{self.key}"
def as_string(self) -> str:
+13 -9
View File
@@ -2,25 +2,28 @@ import struct
class SnowflakeId:
"""
see https://en.wikipedia.org/wiki/Snowflake_ID
"""
SNOWFLAKE_ID_WIDTH = 10 # with in bytes (8 bits each), can be more than long int width (8 bytes)
def __init__(self, _input: bytes = None):
def __init__(self, hi_bits_or_bytes = None, lo_bits = None):
self.bits = [0, 0]
if _input is not None:
if lo_bits is None:
if hi_bits_or_bytes is not None:
i = 0
shift = 8 # starting from Hi value, which is only 2 bytes wide
n = 1
while i < len(_input) and i <= self.SNOWFLAKE_ID_WIDTH:
self.bits[n] |= _input[i] << shift
while i < len(hi_bits_or_bytes) and i <= self.SNOWFLAKE_ID_WIDTH:
self.bits[n] |= hi_bits_or_bytes[i] << shift
if shift == 0:
n -= 1
shift = 56
else:
shift -= 8
i += 1
def __init__(self, hi_bits: int, lo_bits: int):
self.bits = [lo_bits, hi_bits]
else:
self.bits = [lo_bits, hi_bits_or_bytes]
@staticmethod
def from_hex(_input):
@@ -42,7 +45,8 @@ class SnowflakeId:
def __str__(self):
_hex = f"{self.bits[1]:016X}{self.bits[0]:016X}"
return _hex if len(_hex) <= 2 * SnowflakeId.SNOWFLAKE_ID_WIDTH else _hex[(-2 * SnowflakeId.SNOWFLAKE_ID_WIDTH):]
_val = _hex if len(_hex) <= 2 * SnowflakeId.SNOWFLAKE_ID_WIDTH else _hex[(-2 * SnowflakeId.SNOWFLAKE_ID_WIDTH):]
return _val.lower()
def __eq__(self, other):
if isinstance(other, SnowflakeId):
@@ -59,7 +63,7 @@ class SnowflakeId:
def get_bytes(self):
high = struct.pack("!Q", self.bits[1])
low = struct.pack("!Q", self.bits[0])
return high[2:] + low
return high[-2:] + low
def get_bits(self):
return self.bits
+48
View File
@@ -0,0 +1,48 @@
from unittest import TestCase
from amqp.adapter.amq_message_factory import AMQMessageFactory
from amqp.model.snowflake_id import SnowflakeId
class SnowflakeIdTest(TestCase):
def test_equals(self):
_id_1 = AMQMessageFactory.get_instance(1).generate_snowflake_id()
_id_2 = AMQMessageFactory.get_instance(1).generate_snowflake_id()
self.assertFalse(_id_1 == _id_2)
_id_3 = SnowflakeId(_id_1.get_bytes())
self.assertTrue(_id_1 == _id_3)
def test_lt(self):
_id_1 = AMQMessageFactory.get_instance(1).generate_snowflake_id()
_id_2 = AMQMessageFactory.get_instance(1).generate_snowflake_id()
self.assertTrue(_id_1 < _id_2)
def test_from_hex(self):
_factory = AMQMessageFactory.get_instance(1)
_id_1 = _factory.generate_snowflake_id()
_id_1_ser = _id_1.as_string()
_id_2 = SnowflakeId.from_hex(_id_1_ser)
self.assertTrue(_id_1 == _id_2)
with self.assertRaises(RuntimeError) as context:
SnowflakeId.from_hex("100")
self.assertTrue(f"MessageId is {SnowflakeId.SNOWFLAKE_ID_WIDTH * 8} bits wide, it is represented by HEX string "
in str(context.exception))
def test_get_bytes(self):
_factory = AMQMessageFactory.get_instance(1)
_id_1 = _factory.generate_snowflake_id()
_id_1_ser = _id_1.get_bytes()
self.assertEqual(SnowflakeId.SNOWFLAKE_ID_WIDTH, len(_id_1_ser))
def test_get_bits(self):
_factory = AMQMessageFactory.get_instance(1)
_id_1 = _factory.generate_snowflake_id()
self.assertEqual(2, len(_id_1.get_bits()))
def test_as_string(self):
_factory = AMQMessageFactory.get_instance(1)
_id_1 = _factory.generate_snowflake_id()
self.assertEqual(2 * SnowflakeId.SNOWFLAKE_ID_WIDTH, len(_id_1.as_string()))
+1 -1
View File
@@ -132,7 +132,7 @@ class RabbitMQConsumer(RabbitMQProducer):
try:
route_queue_name = self.router.findRoute(message)
queue_name = route_queue_name.queue if route_queue_name else self.TASK_DISPATCH_QUEUE_NAME
logger.info(f"Get Queue size for {queue_name}")
logging.info(f"Get Queue size for {queue_name}")
return max(len(self.awaitingACK), self.channel.message_count(queue_name))
except Exception:
return len(self.awaitingACK)
+24 -8
View File
@@ -14,14 +14,30 @@ class RouteDatabase:
return self.routes
def pattern(self, routing_key: str) -> re.Pattern:
regex = r"\.".join(
[
r".*" if token == "" else r"#" if token == "#" else token
for token in routing_key.split(".")
]
)
if routing_key.endswith("."):
regex += r"\."
breaker = False
tokens = routing_key.split('.')
counter = len(tokens)
regex_parts = []
for token in tokens:
counter -= 1
if not breaker:
if token == "":
res = "[^\\.]*"
elif token == "#":
breaker = True
res = ".*$"
else:
res = token
if counter > 0 and not breaker:
res += "\\."
regex_parts.append(res)
else:
regex_parts.append("")
regex = "".join(filter(lambda x: x != "", regex_parts))
if not routing_key:
return re.compile(".*")
return re.compile(regex)
def matches(self, routing_key_from_route: str, routing_key_from_message: str) -> bool:
+86 -7
View File
@@ -1,24 +1,103 @@
from unittest import TestCase
import pytest
from amqp.adapter.amq_message_factory import AMQMessageFactory
from amqp.adapter.amq_route_factory import AMQRouteFactory
from amqp.model.model import AMQRoute
from amqp.router.route_database import RouteDatabase
ROUTE_1 = "amq::amq1:clever1.book.localhost.#:5:0"
ROUTE_2 = "amq::amq2:clever2.book.localhost.#:5:0"
ROUTE_3 = "amq::amq3:clever3.book.localhost.#:5:0"
ROUTE_7 = "amq::amq7:clever7.book.localhost.#:5:0"
class TestRouteDatabase(TestCase):
@pytest.fixture(autouse=True)
def setup_each_test(self):
self._database = RouteDatabase()
if len(self._database.get_routes()) == 0:
r1 = AMQRouteFactory.from_string(ROUTE_1)
r2 = AMQRouteFactory.from_string(ROUTE_2)
r3 = AMQRouteFactory.from_string(ROUTE_3)
self._database.add_route(r1)
self._database.add_route(r2)
self._database.add_route(r3)
def test_get_routes(self):
self.fail()
_amq_factory = AMQRouteFactory()
_route: AMQRoute = _amq_factory.from_string("amq-clever-pybook::clever-pybook:clever.pybook.localhost.#:5:0")
_routes = self._database.get_routes()
self.assertIsNotNone(_routes)
self.assertEqual(3, len(_routes))
self._database.add_route(_route)
_routes = self._database.get_routes()
self.assertTrue(_route in _routes, "Route was not added")
self.assertEqual(4, len(_routes))
def test_pattern(self):
self.fail()
pattern = self._database.pattern("")
self.assertEqual(".*", pattern.pattern)
pattern = self._database.pattern("..")
self.assertEqual("[^\\.]*\\.[^\\.]*\\.[^\\.]*", pattern.pattern)
pattern = self._database.pattern(".#.#")
self.assertEqual("[^\\.]*\\..*$", pattern.pattern)
pattern = self._database.pattern("..#")
self.assertEqual("[^\\.]*\\.[^\\.]*\\..*$", pattern.pattern)
pattern = self._database.pattern("#")
self.assertEqual(".*$", pattern.pattern)
pattern = self._database.pattern("ba.dc.")
self.assertEqual("ba\\.dc\\.[^\\.]*", pattern.pattern)
pattern = self._database.pattern(".cd.")
self.assertEqual("[^\\.]*\\.cd\\.[^\\.]*", pattern.pattern)
pattern = self._database.pattern("de")
self.assertEqual("de", pattern.pattern)
def test_matches(self):
self.fail()
self.assertTrue(self._database.matches("..", "aa.bb.cc"))
self.assertFalse(self._database.matches("..", "aa.bb."))
self.assertFalse(self._database.matches("..", "aa.bb"))
self.assertFalse(self._database.matches("..", "aa"))
self.assertTrue(self._database.matches("..#", "aa.bb.cc"))
self.assertFalse(self._database.matches("..#", "aa.bb."))
self.assertFalse(self._database.matches("..#", "aa.bb"))
self.assertFalse(self._database.matches("..#", "aa"))
self.assertTrue(self._database.matches(".#", "aa.bb.cc"))
self.assertTrue(self._database.matches(".#", "aa.bb."))
self.assertTrue(self._database.matches(".#", "aa.bb"))
self.assertFalse(self._database.matches(".#", "aa"))
def test_wrap_routes(self):
self.fail()
wrap = self._database.wrap_routes()
self.assertTrue(ROUTE_1 in wrap)
self.assertTrue(ROUTE_2 in wrap)
def test_find_route(self):
self.fail()
message = AMQMessageFactory.get_instance(111).create_request_message(
"POST", "clever3-book.localhost",
"/aa/bb?cc=d", {}, ""
)
route = self._database.find_route(message.domain, message.path)
self.assertIsNotNone(route)
self.assertEqual(ROUTE_3, route.as_string())
route = self._database.find_route("clever3.book.localhost", "/three-hot-dogs")
self.assertIsNotNone(route)
self.assertEqual(ROUTE_3, route.as_string())
route = self._database.find_route("clever-5.book.localhost", "/three-hot-dogs")
self.assertIsNone(route)
def test_add_route(self):
self.fail()
origSize = len(self._database.get_routes())
route = AMQRouteFactory.from_string(ROUTE_7)
self._database.add_route(route)
self.assertEqual(origSize + 1, len(self._database.get_routes()))
def test_remove_route(self):
self.fail()
origSize = len(self._database.get_routes())
route = AMQRouteFactory.from_string(ROUTE_7)
self._database.add_route(route)
self.assertEqual(origSize + 1, len(self._database.get_routes()))
self._database.remove_route(route)
self.assertEqual(origSize, len(self._database.get_routes()))
+95 -12
View File
@@ -1,39 +1,122 @@
from typing import Set
from unittest import TestCase
import pytest
from amqp.adapter.amq_message_factory import AMQMessageFactory
from amqp.adapter.amq_route_factory import AMQRouteFactory
from amqp.model.model import AMQRoute
from amqp.router.router_base import RouterBase
from amqp.router.utils import sanitize_as_rabbitmq_name
ROUTE_1 = "amq::amq1:clever1.book.localhost.#:5:0"
ROUTE_2 = "amq::amq2:clever2.book.localhost.#:5:0"
ROUTE_3 = "amq::amq3:clever3.book.localhost.#:5:0"
ROUTE_6 = "amq::amq6:clever6.book.localhost.#:5:0"
ROUTE_7 = "amq::amq7:clever7.book.localhost.#:5:0"
ROUTE_8 = "amq::amq8:clever8.book.localhost.#:5:0"
ROUTE_9 = "amq::amq9:clever9.book.localhost.#:5:0"
class TestRouterBase(TestCase):
@pytest.fixture(autouse=True)
def setup_each_test(self):
self.base = RouterBase()
self.factory = AMQMessageFactory.get_instance(111)
if len(self.base.get_routes()) == 0:
r1 = AMQRouteFactory.from_string(ROUTE_1)
r2 = AMQRouteFactory.from_string(ROUTE_2)
r3 = AMQRouteFactory.from_string(ROUTE_3)
self.base.add_route(r1)
self.base.add_route(r2)
self.base.add_route(r3)
def test_sanitize_as_rabbitmq_name(self):
self.assertEqual("aa.bb.cc", sanitize_as_rabbitmq_name("aa.bb/cc"))
self.assertEqual("aa.bb.cc", sanitize_as_rabbitmq_name("aa-bb/cc"))
self.assertEqual("aa", sanitize_as_rabbitmq_name("aa&bb/cc"))
self.assertEqual("aa", sanitize_as_rabbitmq_name("aa+bb/cc"))
self.assertEqual("aa", sanitize_as_rabbitmq_name("aa%bb/cc"))
self.assertEqual("aa#bb.cc", sanitize_as_rabbitmq_name("aa#bb/cc"))
self.assertEqual("aa#bb.cc.#", sanitize_as_rabbitmq_name("aa#bb/cc.#"))
self.assertEqual("aa", sanitize_as_rabbitmq_name("aa?bb////cc"))
self.assertEqual("aa.bb.cc", sanitize_as_rabbitmq_name("aa///bb////cc"))
def test_get_routing_key(self):
self.fail()
message = self.factory.create_request_message("POST", "test.me.localhost", "/aa/bb?cc=d", {}, "")
rk = self.base.get_routing_key(message)
self.assertEqual("test.me.localhost.aa.bb", rk)
def test_wrap_routes(self):
self.fail()
wrap = self.base.wrap_routes()
self.assertTrue(ROUTE_1 in wrap)
self.assertTrue(ROUTE_2 in wrap)
def test_wrap_own_routes(self):
self.fail()
_route: AMQRoute = AMQRouteFactory.from_string(ROUTE_7)
self.base.add_own_route(_route)
o_route = self.base.wrap_own_routes()
self.assertEqual(ROUTE_7, o_route)
def test_unwrap_route_list(self):
self.fail()
_routes = self.base.get_routes()
wrapped = self.base.wrap_routes()
unwrapped = self.base.unwrap_route_list(wrapped)
self.assertEqual(len(_routes), len(unwrapped))
self.assertTrue(unwrapped.pop() in _routes)
self.assertTrue(unwrapped.pop() in _routes)
def test_get_routes(self):
self.fail()
_routes = self.base.get_routes()
self.assertEqual(3, len(_routes))
def test_find_route(self):
self.fail()
_route = self.base.find_route("clever3.book.localhost", "/three-hot-dogs")
self.assertIsNotNone(_route)
self.assertEqual(_route.as_string(), ROUTE_3)
_route = self.base.find_route("clever-5.book.localhost", "/three-hot-dogs")
self.assertIsNone(_route)
def test_find_route_by_message(self):
self.fail()
message = AMQMessageFactory.get_instance(111).create_request_message(
"POST", "clever3-book.localhost",
"/aa/bb?cc=d", {}, ""
)
_route: AMQRoute = self.base.find_route_by_message(message)
self.assertIsNotNone(_route)
self.assertEqual(_route.as_string(), ROUTE_3)
def test_add_routes(self):
self.fail()
init_routes: Set[AMQRoute] = self.base.get_routes().copy()
self.base.add_routes(ROUTE_6 + ",amq-dlq:DLX:DeadLetterQueue:#:0:0,:::::,:::")
modified = self.base.get_routes()
self.assertEqual(len(init_routes)+1, len(modified))
wr = self.base.wrap_routes()
self.assertFalse("DLX" in wr)
def test_remove_routes(self):
self.fail()
init_routes: Set[AMQRoute] = self.base.get_routes().copy()
toRemove: AMQRoute = init_routes.pop()
self.base.remove_routes(toRemove.as_string())
modified = self.base.get_routes()
self.assertEqual(len(init_routes), len(modified))
def test_remove_route(self):
self.fail()
origSize = len(self.base.get_routes())
_route = AMQRouteFactory.from_string(ROUTE_7)
self.base.add_route(_route)
self.assertEqual(origSize + 1, len(self.base.get_routes()))
self.base.remove_route(_route)
self.assertEqual(origSize, len(self.base.get_routes()))
def test_add_route(self):
self.fail()
origSize = len(self.base.get_routes())
_route = AMQRouteFactory.from_string(ROUTE_7)
self.base.add_route(_route)
self.assertEqual(origSize + 1, len(self.base.get_routes()))
def test_add_own_route(self):
self.fail()
_route = AMQRouteFactory.from_string(ROUTE_8)
self.base.add_own_route(_route)
self.assertTrue(ROUTE_8 in self.base.wrap_own_routes())
+13 -3
View File
@@ -1,9 +1,19 @@
import re
pattern = re.compile(r"[^a-zA-Z0-9.#/\-]")
def sanitize_as_rabbitmq_name(input_str: str) -> str:
pattern = re.compile(r"[^a-zA-Z0-9.#/\-]")
matcher = pattern.search(input_str)
token = input_str[:matcher.start()] if matcher else input_str
step1 = re.sub(r"[^A-Za-z0-9#]", ".", token)
return re.sub(r"\.+", ".", step1)
return sanitize_as_rabbitmq_domain_name(token)
def sanitize_as_rabbitmq_domain_name(token: str) -> str:
# Step 1: Replace non-alphanumeric characters (except periods) with a period
step1 = re.sub(r'[^A-Za-z0-9.#]', '.', token)
# Step 2: Replace multiple consecutive periods with a single period
step2 = re.sub(r'\.+', '.', step1)
# Step 3: Remove trailing '.' character, if applicable
step_last_char = len(step2) - 1
return step2[:-1] if step_last_char > 0 and step2[step_last_char] == '.' else step2
View File
+7 -1
View File
@@ -19,7 +19,13 @@ from amqp.service.tracing import initialize_trace
logging.basicConfig(level=logging.DEBUG)
logging.info(f"CWD = {os.getcwd()}")
logging.config.fileConfig('amqp/service/logging.conf')
logging_conf_path = os.path.join(os.getcwd(), 'amqp', 'service', 'logging.conf')
if os.path.exists(logging_conf_path):
logging.config.fileConfig(logging_conf_path)
else:
logging_conf_path = os.path.join(os.getcwd(), 'logging.conf')
if os.path.exists(logging_conf_path):
logging.config.fileConfig(logging_conf_path)
class AMQService:
CLEVER_AMQP_CLIENT_VERSION = os.getenv("CLEVER_AMQP_CLIENT_VERSION")
+3 -2
View File
@@ -82,7 +82,8 @@ class AMQServiceMessageHandler:
# map_as_string(amq_message.trace_info), context_with_span, context)
if amq_message.magic == AMQMessageFactory.MAGIC_BYTE_HTTP_REQUEST:
response: AMQResponse = self.http_adapter.on_message(amq_message)
response: AMQResponse = asyncio.run(self.http_adapter.on_message(amq_message))
self.send_reply(properties.reply_to, delivery_tag, channel, response)
else:
logging.error(" [E] Unknown MAGIC value: [%s], don't know how to interpret the message",
amq_message.magic)
@@ -137,7 +138,7 @@ class AMQServiceMessageHandler:
delivery_tag, reply_to, response.id)
reply_props = pika.BasicProperties(
correlation_id=response.id,
content_type=response.content_type
content_type=response.content_type[0]
)
channel.basic_publish(
exchange=AMQ_REPLY_TO_EXCHANGE,
+58
View File
@@ -0,0 +1,58 @@
from pytz import timezone
from typing import Optional
from datetime import datetime, timedelta
from fastapi.security import OAuth2PasswordBearer
import jwt
from core.configs import settings
from core.security import validate_pass, create_password_hash
from schemas.user_schema import UserSchema
oauth2_schema = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V0_STR}/login"
)
async def authenticate(username: str, password: str) -> Optional[UserSchema]:
if username != 'user':
return None
test_hash = create_password_hash('user1234')
if not validate_pass(password, test_hash):
return None
# The returned value at this point should be UserModel, but since we do not have any persistence
# and this is for demo purposes only
return UserSchema(id=0, username='user', hash_pass=test_hash)
def _create_token(token_type: str, lifetime: timedelta, sub: str) -> str:
# https://dataracker.ietf.org/doc/html/rfc7519#section-4.1.3
payload = {}
pt = timezone('UTC')
expires = datetime.now(tz=pt) + lifetime
payload["type"] = token_type
payload["exp"] = expires
payload["iat"] = datetime.now(tz=pt) #Issued-At (iat)
payload["sub"] = str(sub)
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.ALGORITHM)
def create_access_token(sub: str) -> str:
"""
https://jwt.io
"""
return _create_token(
token_type='access_token',
lifetime=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
sub=sub
)
+28
View File
@@ -0,0 +1,28 @@
from pydantic_settings import BaseSettings
from pathlib import Path
class Settings(BaseSettings):
API_V0_STR: str = '/api/v0'
# JSON Web Tokens (https://jwt.io) - Secret utilized for token generation
JWT_SECRET: str = 'qS96E1oCfq5gEZH-ngD91NC2qkcl0cffhNTIDGpF4pw'
# Token was generated by the code below:
"""
import secrets
token: str = secrets.token_urlsafe(32) # com 32 caracteres
"""
ALGORITHM: str = 'HS256'
# Token timeout with 1h
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
JOBS_FILE_PATH: Path = 'jobs'
BASE_PATH: Path = '.'
class Config:
case_sensitive = True
settings: Settings = Settings()
+61
View File
@@ -0,0 +1,61 @@
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
#from jose import jwt, JWTError
from pydantic import BaseModel
from schemas.user_schema import UserSchema
from core.auth import oauth2_schema, authenticate
#from core.configs import settings
from core.security import create_password_hash
class TokenData(BaseModel):
userid: Optional[str] = None
async def get_current_user(
token: str = Depends(oauth2_schema)
) -> UserSchema:
credential_exception: HTTPException = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User provided credentials are not correct.',
headers={"WWW-Authenticate": "Bearer"}
)
try:
#payload = jwt.decode(
# token,
# settings.JWT_SECRET,
# algorithms=[settings.ALGORITHM],
# options={"verify_aud": False}
#)
userid: str = 'user' #payload.get("sub")
if userid is None:
raise credential_exception
token_data: TokenData = TokenData(userid=userid)
except Exception:
raise credential_exception
if int(token_data.userid) != 0:
raise credential_exception
# The returned value at this point should be UserModel, but since we do not have any persistence
# and this is for demo purposes only
return UserSchema(id=token_data.userid, username='user', hash_pass=create_password_hash('user1234'))
security = HTTPBasic()
async def get_current_username(credentials: HTTPBasicCredentials = Depends(security)) -> str:
user = await authenticate(credentials.username, credentials.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
+20
View File
@@ -0,0 +1,20 @@
from passlib.context import CryptContext
CRIPTO = CryptContext(schemes=['bcrypt'], deprecated='auto') # Automatically workaround deprecated APIs
def validate_pass(password: str, hash_pass: str) -> bool:
"""
Verifies if a given password matches the expected hash.
It compares the user provided text password with the stored hash of the expected password, that
is currently stored in the system.
"""
return CRIPTO.verify(password, hash_pass)
def create_password_hash(password: str) -> str:
"""
Return the hash of the password
"""
return CRIPTO.hash(password)
+3 -2
View File
@@ -1,9 +1,10 @@
aiohttp~=3.11.11
fastapi==0.115.6
pika~=1.3.2
opentelemetry-api
opentelemetry-sdk
opentelemetry-exporter-otlp
opentelemetry-exporter-prometheus
opentelemetry-instrumentation-fastapi
opentelemetry-instrumentation-flask
opentelemetry-instrumentation-pika
coverage
pytest
+111
View File
@@ -0,0 +1,111 @@
import logging
from fastapi import APIRouter, status, Depends, HTTPException
from fastapi import Path as FPath
from fastapi import Query
from fastapi import UploadFile
from fastapi.responses import FileResponse, Response
from schemas.request_schema import RequestSchema
from schemas.user_schema import UserSchema
from schemas.job_id_schema import JobIdSchema
from core.deps import get_current_user
logger = logging.getLogger('Benchmark')
# Enable propagation to the root logger
logger.propagate = True
router = APIRouter()
# POST Convert
@router.post('/',
status_code=status.HTTP_201_CREATED, summary='Creates a new benchmark job',
description='Creates a new benchmark job/request, however if additional input data files '
'are required they must be provided through an addition PUT method call. '
'When all the job files have been uploaded a PUT method call must be invoked to'
'set the job/request status to ReadyForProcessing.',
responses={
201: {'model': JobIdSchema,
'description': 'Job updated with new input files'},
507: {'description': 'The server possibly ran out of disk space or an internal server error occurred'}
})
async def create_benchmark_job(unstructured: UploadFile,
ontology: UploadFile,
ground_truth: UploadFile,
ready_for_processing: bool = Query(
title='Defines if the job is ready for processing',
description='If set to true the job will be marked as ready '
'to start being processed. No more files can uploaded '
'for this benchmark job, after being marked as ready',
examples=[True, False]),
authenticated_user: UserSchema = Depends(get_current_user)):
logging.info('===> POST /benchmark: Started Benchmark job creation')
logging.info('===> POST arguments: unstructured=%s, ontology=%s, ground_truth=%s, ready_for_processing=%s',
unstructured, ontology, ground_truth, ready_for_processing)
return JobIdSchema(job_id='123456-TestKG')
@router.put('/{job_id}', status_code=status.HTTP_202_ACCEPTED, response_model=RequestSchema,
summary='Append additional files to the benchmark request.',
description='Append additional files to the benchmark request. Only possible if Job/Request '
'is still in the Created state.',
responses={
202: {'model': RequestSchema,
'description': 'Job updated with new input files'},
400: {'description': 'Job does not correspond to a Benchmark'},
404: {'description': 'Job ID not found or invalid index'},
412: {'description': 'Job is in state that does not allow it to be updated.'},
500: {'description': 'A server error occurred while processing the request'}
})
async def update_benchmark_job(unstructured: UploadFile,
ontology: UploadFile,
ground_truth: UploadFile,
job_id: str = FPath(
title='The Job/Request identifier',
description='The identifier string for the desired Job/Request '
'to be updated with additional files for processing',
example='006ea377-baf1-4e04-b18b-37781578d81a'),
ready_for_processing: bool = Query(
title='Defines if the job is ready for processing',
description='If set to true the job will be marked as ready '
'to start being processed. No more files can uploaded '
'for this benchmark job, after being marked as ready',
examples=[True, False]),
authenticated_user: UserSchema = Depends(get_current_user)):
logging.info(f'===> PUT /benchmark/{job_id}: Append additional files to the benchmark request')
logging.info('===> PUT arguments: unstructured=%s, ontology=%s, ground_truth=%s, ready_for_processing=%s',
unstructured, ontology, ground_truth, ready_for_processing)
return RequestSchema(job_id=job_id, status='Updated')
class JSONLResponse(Response):
media_type = "application/jsonl"
@router.get('/{request_id}', summary='Polls or Retrieves a benchmark KG by id and index',
description='Polls or Retrieves a processed benchmark KG by id and index. '
'If the KG is not ready yet it sends a NO CONTENT (204) HTTP status.',
response_class=JSONLResponse,
response_description='Response is in the form of a jsonl file',
responses={
200: {'content': {'c': {}},
'description': 'The benchmark KG extracted data'},
204: {'description': 'Job is not completed yet'},
404: {'description': 'Job ID not found or invalid index'},
412: {'description': 'Job does not correspond to a Benchmark'},
500: {'description': 'A server error occurred while processing the request'}
})
async def get_benchmark_kg(request_id: str =
FPath(title='The request id',
description='The id of the request for which the results are to be retrieved',
example='3286f418-8141-450f-8ad5-f6a5c01a0ce9'),
index: int = Query(title='The index of the result to be retrieved',
description='The index of the result to retrieved starting at 0 and '
'up to the number of benchmark files -1.',
ge=0,
example=0),
authenticated_user: UserSchema = Depends(get_current_user)):
logging.info(f'===> GET /benchmark/{request_id}?index={index}: Polls or Retrieves a benchmark KG by id and index')
return JSONLResponse(content=f'{"index": {index}, "request_id": "{request_id}", "data": "data"}')
+74
View File
@@ -0,0 +1,74 @@
from typing import List, Optional, Annotated
import logging
from fastapi import APIRouter, status, Depends, HTTPException
from fastapi import Path as FPath
from schemas.request_schema import RequestSchema
from schemas.user_schema import UserSchema
from core.deps import get_current_user
logger = logging.getLogger('Jobs')
# Enable propagation to the root logger
logger.propagate = True
router = APIRouter()
@router.get('/{request_id}', summary='Obtains the status of a given job',
description='Obtains the status of an existing job by its id',
status_code=status.HTTP_200_OK,
responses={
200: {'description': 'Job status retrieved successfully'},
404: {'description': 'Job not found'},
500: {'description': 'A server error occurred while processing the request'}
})
async def get_job_status(request_id: str = FPath(title='Job ID',
description='The id of the job for which the status is to be queried'),
authenticated_user: UserSchema = Depends(get_current_user)):
logging.info(f'===> GET /jobs/{request_id}: Obtains the status of a given job')
return "JobStatus.Completed"
@router.get('/', summary='List all user submitted requests',
description='List all user submitted requests along with their status',
status_code=status.HTTP_200_OK,
response_model=List[RequestSchema],
response_description='User submitted job requests',
responses={
200: {'description': 'User submitted job requests'},
500: {'description': 'A server error occurred while processing the request'}
})
async def get_requests(authenticated_user: UserSchema = Depends(get_current_user)):
logging.info('===> GET /jobs: List all user submitted requests')
return []
@router.delete('/{request_id}', summary='Deletes a completed or failed job',
description='Deletes a completed or failed job by its request_id',
status_code=status.HTTP_204_NO_CONTENT,
responses={
204: {'description': 'Job data was successfully deleted'},
404: {'description': 'Job ID not found'},
412: {'description': 'Job is in state that does not allow deletion'},
500: {'description': 'A server error occurred while processing the request'}
})
async def delete_job(request_id: str = FPath(title='Request ID',
description='The id of the failed or completed request to be deleted'),
authenticated_user: UserSchema = Depends(get_current_user)):
logging.info(f'===> DELETE /jobs/{request_id}: Deletes a completed or failed job')
return 204
@router.put('/{request_id}', summary='Retries execution of a given job',
description='Retries the execution of a given job by its id, provided the current job status is failed',
status_code=status.HTTP_202_ACCEPTED,
responses={
202: {'model': RequestSchema,
'description': 'The updated job status and its configuration'},
406: {'description': 'Job is an state that does not allow it to be retried'},
500: {'description': 'A server error occurred while processing the request'}
})
async def retry_job(request_id: str = FPath(title='Job ID',
description='The id of the job to be retried'),
authenticated_user: UserSchema = Depends(get_current_user)):
logging.info(f'===> PUT /jobs/{request_id}: Retries execution of a given job')
return RequestSchema(job_id=request_id, status='Failed')
+77
View File
@@ -0,0 +1,77 @@
import logging
from fastapi import APIRouter, status, Depends
from fastapi import Path as FPath
from fastapi import UploadFile
from schemas.user_schema import UserSchema
from schemas.job_id_schema import JobIdSchema
from core.deps import get_current_user
logger = logging.getLogger('Unstructured')
# Enable propagation to the root logger
logger.propagate = True
router = APIRouter()
# POST Convert
@router.post('/with_ontology', status_code=status.HTTP_201_CREATED, summary='Convert unstructured text to KG',
description='Converts unstructured text to a Knowledge Graph according to the provided ontology',
response_description='Returns the Job/Request ID for reference in future interactions',
responses={
200: {'model': JobIdSchema,
'description': 'The KG extraction job ID'},
400: {'description': 'Input parameters are not compliant with request'},
500: {'description': 'A server error occurred while processing the request'}
})
async def create_extract_with_ontology(unstructured: UploadFile,
ontology: UploadFile,
ontology_spec: UploadFile,
authenticated_user: UserSchema = Depends(get_current_user)):
logger.debug('====> POST /unstructured/with_ontology: Started convertToKG')
logger.debug('====> arguments: unstructured=%s, ontology=%s, ontology_spec=%s', unstructured, ontology, ontology_spec)
return JobIdSchema(job_id='123456-TestKG')
# POST Convert
@router.post('/with_wildcards', status_code=status.HTTP_201_CREATED,
summary='Convert unstructured text to KG with Wildcards filtering',
description='Converts unstructured text to a Knowledge Graph according to the provided ontology, while'
'applying a filtering based on the Wildcards ontology',
response_description='Knowledge Graph defined by RDF triplets in JSON format',
responses={
200: {'model': JobIdSchema,
'description': 'The KG extraction job ID'},
400: {'description': 'Input parameters are not compliant with request'},
500: {'description': 'A server error occurred while processing the request'}
})
async def create_extract_with_ontology(unstructured: UploadFile,
ontology: UploadFile,
ontology_spec: UploadFile,
wildcards: UploadFile,
authenticated_user: UserSchema = Depends(get_current_user)):
logger.debug('====> POST /unstructured/with_wildcards: Started convertToKG with Wildcards filtering')
logger.debug('====> arguments: unstructured=%s, ontology=%s, ontology_spec=%s, wildcards=%s',
unstructured, ontology, ontology_spec, wildcards)
return JobIdSchema(job_id='123456-TestKG')
@router.get('/{request_id}', summary='Polls or Retrieves a KG by id',
description='Polls or Retrieves a processed KG by id. If the KG is not ready yet it sends a '
'NO CONTENT (204) HTTP status',
response_description='Response is in the form of json data',
responses={
200: {'description': 'The KG extracted data'},
204: {'description': 'Job is not completed yet'},
404: {'description': 'Job ID not found'},
412: {'description': 'Job does not correspond to a KG extraction job'},
500: {'description': 'A server error occurred while processing the request'}
})
async def get_kg(request_id: str = FPath(title='The job id',
description='The id of the job for which the results are to be retrieved'),
authenticated_user: UserSchema = Depends(get_current_user)):
logger.debug(f'====> GET /unstructured/{request_id}: Started convertToKG with Wildcards filtering')
+5
View File
@@ -0,0 +1,5 @@
from pydantic import BaseModel
class JobIdSchema(BaseModel):
job_id: str
+17
View File
@@ -0,0 +1,17 @@
from typing import Optional, List
from datetime import datetime
from pydantic import BaseModel
class RequestSchema(BaseModel):
id: str = None
created: datetime
type: str
status: str
retries_count: int
unstructured_sources: List[str]
ontology_sources: List[str]
ontology_spec_sources: List[str]
wildcards_sources: List[str]
ground_truth_sources: List[str]
+9
View File
@@ -0,0 +1,9 @@
from typing import Optional
from pydantic import BaseModel
class UserSchema(BaseModel):
id: Optional[int] = None
username: str
hash_pass: str