782 lines
35 KiB
Python
782 lines
35 KiB
Python
"""
|
|
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.
|
|
"""
|
|
|
|
import asyncio
|
|
import enum
|
|
import inspect
|
|
import json
|
|
import pathlib
|
|
import re
|
|
from asyncio import AbstractEventLoop
|
|
from collections import defaultdict
|
|
from typing import Any, Callable, Coroutine, Dict, List
|
|
|
|
from aio_pika.abc import (
|
|
AbstractExchange,
|
|
AbstractRobustChannel,
|
|
AbstractRobustConnection,
|
|
)
|
|
from aiohttp import ClientResponse, ClientSession
|
|
from fastapi import HTTPException, UploadFile
|
|
from fastapi.params import File
|
|
from fastapi.routing import APIRoute
|
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
from pydantic import BaseModel
|
|
from starlette.datastructures import Headers
|
|
from starlette.responses import FileResponse
|
|
|
|
from amqp.adapter import serializer
|
|
from amqp.adapter.data_parser import AMQDataParser
|
|
from amqp.adapter.data_response_factory import DataResponseFactory
|
|
from amqp.adapter.file_handler import FileHandler, StreamingFileHandler
|
|
from amqp.adapter.logging_utils import (
|
|
logging_debug,
|
|
logging_error,
|
|
logging_info,
|
|
logging_warning,
|
|
)
|
|
from amqp.adapter.pydantic_serializer import python_type_to_json
|
|
from amqp.adapter.type_descriptor import TypeDescriptor
|
|
from amqp.config.amq_configuration import AMQConfiguration
|
|
from amqp.model.model import AMQErrorMessage, AMQMessage, DataMessage, DataResponse
|
|
from amqp.router.router_base import AMQ_REPLY_TO_EXCHANGE
|
|
from amqp.router.utils import await_result
|
|
|
|
g_extra_init_data: dict = dict()
|
|
|
|
|
|
async def _create_reply_exchange_and_queue(
|
|
connection: AbstractRobustConnection,
|
|
) -> tuple[AbstractRobustChannel, AbstractExchange, str]:
|
|
"""
|
|
Create objects for reply.
|
|
Return (channel for reply queue, exchange for reply, reply queue name)
|
|
"""
|
|
|
|
# Wrap calls to RabbitMQ API to make it Awaitable, so it can be run thread-safe with
|
|
# correct event loop. This is needed because the RabbitMQ API is not thread-safe and needs
|
|
# to be called in the context of event loop the first RabbitMQ connection was created with.
|
|
_channel = await connection.channel()
|
|
_exchange_name: str = AMQ_REPLY_TO_EXCHANGE
|
|
_exchange = await _channel.get_exchange(name=_exchange_name, ensure=True)
|
|
_queue = await _channel.declare_queue(exclusive=False) # Declare a unique, non-exclusive queue
|
|
_queue_name = _queue.name
|
|
await _queue.bind(
|
|
exchange=_exchange_name,
|
|
routing_key=_queue_name, # Use queue name as routing key
|
|
)
|
|
logging_debug(f"Publishing to exchange: {_exchange_name}, q: {_queue_name}")
|
|
return _channel, _exchange, _queue_name
|
|
|
|
|
|
async def _convert_file_response(
|
|
obj: FileResponse,
|
|
connection: AbstractRobustConnection,
|
|
file_handler: FileHandler,
|
|
loop: AbstractEventLoop | None,
|
|
) -> str:
|
|
"""
|
|
Converts a FileResponse object to a JSON string containing the filename and stream name.
|
|
The file content itself is transmitted through the named stream (which is implemented as
|
|
temporary dedicated queue.
|
|
"""
|
|
# All calls to RabbitMQ API have to be called threadsafe, because those appear to be attached
|
|
# to parent event loop, and need to execute at that event loop, not the current one
|
|
# allow coroutine to execute. calling _future.result() will block the entire thread
|
|
# and the coroutine will never execute
|
|
_channel, _exchange, _routing_key = await await_result(
|
|
asyncio.run_coroutine_threadsafe(_create_reply_exchange_and_queue(connection), loop)
|
|
)
|
|
# publish_file also needs RabbitMQ API, ensure the loop is passed as argument
|
|
(_stream_name, _file_size) = await file_handler.publish_file(
|
|
_exchange, obj.path, _routing_key, loop
|
|
)
|
|
# as above, but luckily this time we don't need to wait for the result
|
|
asyncio.run_coroutine_threadsafe(_channel.close(), loop)
|
|
return json.dumps(
|
|
{
|
|
"files": [
|
|
{
|
|
"filename": obj.filename,
|
|
"mediaType": "application/octet-stream",
|
|
"streamName": _stream_name,
|
|
"fileSize": _file_size,
|
|
}
|
|
]
|
|
}
|
|
)
|
|
|
|
|
|
def is_likely_json(text: str) -> bool:
|
|
"""Quick test to see if should convert the response string to JSON format."""
|
|
if not isinstance(text, str):
|
|
return False
|
|
_text = text.strip()
|
|
return (_text.startswith("{") and _text.endswith("}")) or (
|
|
_text.startswith("[") and _text.endswith("]")
|
|
)
|
|
|
|
|
|
# ============= CleverThisServiceAdapter =============
|
|
class CleverThisServiceAdapter:
|
|
"""
|
|
A base class for CleverThis service adapter that handles the incoming AMQP messages and routes
|
|
them to the appropriate CleverThisService API endpoint.
|
|
IMPORTANT: This class is not intended to be used directly. It should be subclassed for
|
|
each CleverThis service and override the methods to handle the specific API calls.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
amq_configuration: AMQConfiguration,
|
|
routes: List[APIRoute],
|
|
session: ClientSession = None,
|
|
authenticated_user_arg_name: str = "authenticated_user",
|
|
extra_init_data=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.
|
|
"""
|
|
if extra_init_data is None:
|
|
extra_init_data = dict()
|
|
self.authenticated_user_arg_name = authenticated_user_arg_name
|
|
self.amq_configuration = amq_configuration
|
|
self.session = session
|
|
self.service_host = self.amq_configuration.dispatch.service_host
|
|
self.service_port = self.amq_configuration.dispatch.service_port
|
|
self.loop: AbstractEventLoop | None = None
|
|
self.require_authenticated_user = (
|
|
self.amq_configuration.amq_adapter.require_authenticated_user
|
|
)
|
|
self.routes = self.group_routes_by_method(api_routes=routes)
|
|
global g_extra_init_data
|
|
g_extra_init_data = extra_init_data or {}
|
|
|
|
def get_endpoint(self, endpoint: Any) -> Any:
|
|
"""
|
|
Optionally, Service can override this method to alter the endpoint mapping.
|
|
This is useful for example when the CleverThis service has a JSON wrapper
|
|
around the endpoint, like CleverSwarm, which might be the preferred endpoint to call.
|
|
|
|
:param endpoint: The original endpoint to be called.
|
|
:return: The actual endpoint to be called.
|
|
"""
|
|
return endpoint
|
|
|
|
def clone_and_adapt_route(self, original_route: APIRoute) -> APIRoute:
|
|
"""
|
|
Creates a clone of APIRoute. Checks if there's preferred endpoint with json serialization.
|
|
It also adjusts 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
|
|
# 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(
|
|
path=original_route.path,
|
|
endpoint=self.get_endpoint(
|
|
original_route.endpoint
|
|
), # Service can alter the endpoint mapping here
|
|
response_model=original_route.response_model,
|
|
status_code=original_route.status_code,
|
|
tags=original_route.tags.copy() if original_route.tags else None,
|
|
dependencies=(
|
|
original_route.dependencies.copy() if original_route.dependencies else None
|
|
),
|
|
summary=original_route.summary,
|
|
description=original_route.description,
|
|
response_description=original_route.response_description,
|
|
responses=(original_route.responses.copy() if original_route.responses else None),
|
|
deprecated=original_route.deprecated,
|
|
name=original_route.name,
|
|
methods=original_route.methods.copy() if original_route.methods else None,
|
|
operation_id=original_route.operation_id,
|
|
response_model_include=original_route.response_model_include,
|
|
response_model_exclude=original_route.response_model_exclude,
|
|
response_model_by_alias=original_route.response_model_by_alias,
|
|
response_model_exclude_unset=original_route.response_model_exclude_unset,
|
|
response_model_exclude_defaults=original_route.response_model_exclude_defaults,
|
|
response_model_exclude_none=original_route.response_model_exclude_none,
|
|
include_in_schema=original_route.include_in_schema,
|
|
response_class=original_route.response_class,
|
|
dependency_overrides_provider=original_route.dependency_overrides_provider,
|
|
callbacks=(original_route.callbacks.copy() if original_route.callbacks else None),
|
|
openapi_extra=(
|
|
original_route.openapi_extra.copy() if original_route.openapi_extra else None
|
|
),
|
|
generate_unique_id_function=original_route.generate_unique_id_function,
|
|
)
|
|
_new_route.path_regex = re.compile(_new_regex_string)
|
|
return _new_route
|
|
|
|
def group_routes_by_method(self, api_routes: List[APIRoute]) -> Dict[str, List[APIRoute]]:
|
|
"""
|
|
Groups API routes by their HTTP method. This is important to ensure that URLPath is matched
|
|
correctly, as different methods may share the same URLPath (e.g. GET, POST PUT, DELETE).
|
|
"""
|
|
_method_to_routes = defaultdict(list) # Automatically initializes new lists for new keys
|
|
for _route in api_routes:
|
|
if (
|
|
isinstance(_route, APIRoute) and _route.methods
|
|
): # Ensure methods exist (should always be true for valid routes)
|
|
for method in _route.methods:
|
|
_method_to_routes[method].append(self.clone_and_adapt_route(_route))
|
|
|
|
return dict(_method_to_routes)
|
|
|
|
def get_content_type(self, message_headers: Dict[str, List[str]]) -> str:
|
|
"""
|
|
Pull the value of the Content-Type header from the message headers.
|
|
:param message_headers: The headers of the message.
|
|
return: The content type of the message.
|
|
"""
|
|
for header, values in message_headers.items():
|
|
if header.lower() == "content-type":
|
|
return values[0].split(";")[0]
|
|
return "application/json"
|
|
|
|
async def get_current_user(self, token: str) -> object:
|
|
"""
|
|
To Be Overriden in subclass for given CleverThis Service, to return the current user based
|
|
on the token, in format appropriate for the service.
|
|
|
|
:param token: The token to be used for authentication.
|
|
|
|
:return: A user object or None if the user is not authenticated.
|
|
"""
|
|
return None
|
|
|
|
async def on_message(self, message: AMQMessage) -> DataResponse:
|
|
"""
|
|
Called when a message is received from the AMQP.
|
|
:param message:
|
|
:return: AMQP response class with operation status code and optional error details.
|
|
"""
|
|
_auth_user: Any | None = None
|
|
_amq_response: Any | None = None
|
|
|
|
logging_debug(f"on_message: require_authenticated_user={self.require_authenticated_user}")
|
|
if self.require_authenticated_user:
|
|
_auth: str = message.headers().get("Authorization", "")
|
|
logging_debug(f"Auth: {_auth}")
|
|
if isinstance(_auth, List):
|
|
_auth = _auth[0]
|
|
if "Bearer" in _auth:
|
|
try:
|
|
token = _auth.split(" ")[1]
|
|
logging_info(f"Token: {token}")
|
|
_auth_user = await self.get_current_user(token)
|
|
except Exception as error:
|
|
logging_error(f"on_message: Error getting user: {error}")
|
|
|
|
if _auth_user or not self.require_authenticated_user or message.path().endswith("/login"):
|
|
method = message.method().lower()
|
|
if method == "get":
|
|
_amq_response = await self.get(message, _auth_user)
|
|
elif method == "put":
|
|
_amq_response = await self.put(message, _auth_user)
|
|
elif method == "post":
|
|
_amq_response = await self.post(message, _auth_user)
|
|
elif method == "head":
|
|
_amq_response = await self.head(message, _auth_user)
|
|
elif method == "delete":
|
|
_amq_response = await self.delete(message, _auth_user)
|
|
else:
|
|
raise ValueError(f"Unexpected HTTP method: {message.method()}")
|
|
else:
|
|
_amq_response = DataResponseFactory.of_error_message(
|
|
message.id(),
|
|
AMQErrorMessage(401, "Unauthorized", "Unauthorized"),
|
|
message.trace_info(),
|
|
)
|
|
logging_info(
|
|
f"ALL DONE in on_message id:{message.id()}, resp code:{_amq_response.response_code()}"
|
|
)
|
|
return _amq_response
|
|
|
|
async def _authorize_forward_request(
|
|
self, message: DataMessage, request_coroutine, auth_user: Any
|
|
) -> DataResponse:
|
|
"""
|
|
This method is used to authorize the request and forward it to the CleverThis Service API.
|
|
Used only for loose / 3rd party mode of coupling with the Service
|
|
"""
|
|
# AMQDataParser.parse_request_body(message)
|
|
# TODO: implement authorization logic here - ensure the security related headers are set
|
|
# This depends on what kind of security is required by the CleverThis service.
|
|
# However at this time there is no loosely coupled Service.
|
|
|
|
return await request_coroutine
|
|
|
|
# ==============================================================================================
|
|
# ===================== C l e v e r T h i s A P I m e t h o d s ========================
|
|
# ==============================================================================================
|
|
|
|
async def get(self, message: AMQMessage, auth_user: Any) -> DataResponse:
|
|
"""
|
|
Handles the GET requests for the CleverThis API. In tight coupling (self.session is None),
|
|
use path, path params and query string to determine the endpoint method to invoke.
|
|
In loose coupling (self.session is not None), forward the request as REST request using
|
|
the session, i.e. attempt to call the REST endpoint directly using HTTP protocol.
|
|
|
|
:param message: message from the AMQP that contains the GET request
|
|
:param auth_user: user authenticated by the API Gateway, as UserSchem
|
|
|
|
:return: DataResponse to be sent back to the API Gateway via AMQP
|
|
"""
|
|
|
|
if self.session is None:
|
|
return await self.handle_no_body_payload(message, auth_user, "GET")
|
|
|
|
# Forwards as REST - 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}"
|
|
)
|
|
_http_resp: ClientResponse = await self.session.get(url, headers=headers)
|
|
return DataResponseFactory.of(
|
|
message.id(),
|
|
_http_resp.status,
|
|
_http_resp.content_type,
|
|
await _http_resp.read(),
|
|
message.trace_info(),
|
|
)
|
|
|
|
async def put(self, message: AMQMessage, auth_user: Any) -> DataResponse:
|
|
"""
|
|
Handles the PUT requests for the CleverThis API. Uses path, path params, query string and
|
|
body payload to determine the endpoint method to invoke and its input arguments
|
|
In loose coupling (self.session is not None), attempt to call the REST endpoint directly
|
|
using HTTP protocol.
|
|
|
|
:param message: message from the AMQP that contains the PUT request
|
|
:param auth_user: user authenticated by the API Gateway, as UserSchem
|
|
|
|
:return: DataResponse to be sent back to the API Gateway via AMQP
|
|
"""
|
|
if self.session is None:
|
|
return await self.handle_body_payload(message, auth_user, "PUT")
|
|
|
|
# Forwards as REST - try to invoke the service directly on this path
|
|
return await self._authorize_forward_request(
|
|
message,
|
|
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: Any) -> DataResponse:
|
|
"""
|
|
Handles the POST requests for the CleverThis API. Uses path, path params, query string and
|
|
body payload to determine the endpoint method to invoke and its input arguments
|
|
In loose coupling (self.session is not None), attempt to call the REST endpoint directly
|
|
using HTTP protocol.
|
|
|
|
:param message: message from AMQP that contains the POST request, including body and headers
|
|
:param auth_user: user authenticated by the API Gateway, as UserSchem
|
|
|
|
:return: DataResponse to be sent back to the API Gateway via AMQP
|
|
"""
|
|
if self.session is None:
|
|
return await self.handle_body_payload(message, auth_user, "POST")
|
|
|
|
return await self._authorize_forward_request(
|
|
message,
|
|
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 head(self, message: AMQMessage, auth_user: Any) -> DataResponse:
|
|
"""
|
|
Handles the HEAD requests for the CleverThis API. In tight coupling (self.session is None),
|
|
use path, path params and query string to determine the endpoint method to invoke.
|
|
In loose coupling (self.session is not None), HEAD call is not supported.
|
|
|
|
:param message: message from AMQP containing the HEAD request, including body and headers
|
|
:param auth_user: user authenticated by the API Gateway, as UserSchema
|
|
|
|
:return: DataResponse to be sent back to the API Gateway via AMQP
|
|
"""
|
|
if self.session is None:
|
|
return await self.handle_no_body_payload(message, auth_user, "HEAD")
|
|
|
|
raise NotImplementedError
|
|
|
|
async def delete(self, message: AMQMessage, auth_user: Any) -> DataResponse:
|
|
"""
|
|
Handles the DELETE requests for the CleverThis API.
|
|
|
|
:param message: message from AMQP that contains the DELETE request, including body & headers
|
|
:param auth_user: user authenticated by the API Gateway, as UserSchema
|
|
|
|
:return: DataResponse to be sent back to the API Gateway via AMQP
|
|
"""
|
|
if self.session is None:
|
|
return await self.handle_no_body_payload(message, auth_user, "DELETE")
|
|
|
|
return await self._authorize_forward_request(
|
|
message,
|
|
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 options(self, message: AMQMessage, auth_user: Any) -> DataResponse:
|
|
"""
|
|
Handles the OPTIONS requests. In tight coupling (self.session is None),
|
|
use path, path params and query string to determine the endpoint method to invoke.
|
|
In loose coupling (self.session is not None), forward the request as REST request using
|
|
the session, i.e. attempt to call the REST endpoint directly using HTTP protocol.
|
|
|
|
:param message: message from AMQP that contains the OPTIONS request, including headers
|
|
:param auth_user: user authenticated by the API Gateway, as UserSchema
|
|
|
|
:return: DataResponse to be sent back to the API Gateway via AMQP
|
|
"""
|
|
if self.session is None:
|
|
return await self.handle_no_body_payload(message, auth_user, "OPTIONS")
|
|
|
|
raise NotImplementedError
|
|
|
|
async def patch(self, message: AMQMessage, auth_user: Any) -> DataResponse:
|
|
"""
|
|
Handles the PATCH requests for the CleverBRAG API.
|
|
|
|
:param message: message from AMQP that contains the PATCH request, including body & headers
|
|
:param auth_user: user authenticated by the API Gateway, as UserSchema
|
|
|
|
:return: DataResponse to be sent back to the API Gateway via AMQP
|
|
"""
|
|
if self.session is None:
|
|
return await self.handle_no_body_payload(message, auth_user, "PATCH")
|
|
|
|
return await self._authorize_forward_request(
|
|
message,
|
|
self.session.post(
|
|
f"http://{self.service_host}:{self.service_port}{message.path()}",
|
|
data=message.body(),
|
|
headers={**message.headers(), **message.trace_info()},
|
|
),
|
|
auth_user,
|
|
)
|
|
|
|
# ==============================================================================================
|
|
# ================= C l e v e r S w a r m p a y l o a d h a n d l e r s ==================
|
|
# ==============================================================================================
|
|
|
|
async def handle_no_body_payload(
|
|
self, message: AMQMessage, auth_user: Any, method: str
|
|
) -> DataResponse:
|
|
"""
|
|
Handles the requests that do not have a body payload.
|
|
:param message: message from the AMQP that contains the request
|
|
:param auth_user: user authenticated by the API Gateway, as UserSchem
|
|
:return: DataResponse to be sent back to the API Gateway via AMQP
|
|
"""
|
|
_routes = self.routes.get(method, [])
|
|
_path, _args = AMQDataParser.parse_get_url(message.path())
|
|
for _route in _routes:
|
|
_match = _route.path_regex.match(_path)
|
|
if _match:
|
|
_args.update(_match.groupdict())
|
|
_signature_args = inspect.signature(_route.endpoint).parameters
|
|
if self.authenticated_user_arg_name in _signature_args.keys():
|
|
_args[self.authenticated_user_arg_name] = auth_user
|
|
# If the path matches, call the corresponding function
|
|
return await self.call_cleverthis_api(
|
|
message,
|
|
_args,
|
|
api_method=_route.endpoint,
|
|
success_code=_route.status_code,
|
|
)
|
|
|
|
return DataResponseFactory.of(
|
|
message.id(),
|
|
404,
|
|
"text/plain",
|
|
str(f"Destination location [{message.path()}] does not exists").encode("utf-8"),
|
|
message.trace_info(),
|
|
)
|
|
|
|
async def handle_body_payload(
|
|
self, message: AMQMessage, auth_user: Any, method: str
|
|
) -> DataResponse:
|
|
"""
|
|
Handles the requests that have a body payload, including Multipart data.
|
|
:param message: message from the AMQP that contains the request
|
|
:param auth_user: user authenticated by the API Gateway, as UserSchem
|
|
:param method: HTTP method (POST, PUT, etc.)
|
|
:return: DataResponse to be sent back to the API Gateway via AMQP
|
|
"""
|
|
_form_data = AMQDataParser.parse_request_body(message)
|
|
_routes = self.routes.get(method, [])
|
|
_path, _query_args = AMQDataParser.parse_get_url(message.path())
|
|
for _route in _routes:
|
|
_match = _route.path_regex.match(_path)
|
|
if _match:
|
|
# If the path matches, call the corresponding function
|
|
_form_data.update(_match.groupdict())
|
|
_form_data.update(_query_args)
|
|
for _body_param in _route.dependant.body_params:
|
|
if _body_param.name in _form_data:
|
|
_file_data = _form_data.get(_body_param.name, None)
|
|
actual_filepath = message.extra_data.get(_body_param.name, None)
|
|
_form_data[_body_param.name] = (
|
|
(
|
|
_body_param.type_.__name__ == "UploadFile"
|
|
or isinstance(_body_param.field_info, File)
|
|
)
|
|
and _file_data is not None
|
|
and actual_filepath is not None
|
|
and UploadFile(
|
|
file=pathlib.Path(actual_filepath).open("rb"),
|
|
size=_file_data.get("size"),
|
|
filename=_file_data.get("filename"),
|
|
headers=Headers({"Content-Type": _file_data.get("mediaType")}),
|
|
)
|
|
or _form_data[_body_param.name]
|
|
)
|
|
|
|
_verified_args = {}
|
|
_required_args = getattr(_route.endpoint, "__annotations__", {})
|
|
_signature_args = inspect.signature(_route.endpoint).parameters
|
|
for arg in _required_args:
|
|
if arg in _form_data:
|
|
_verified_args[arg] = _form_data[arg]
|
|
else:
|
|
# needed for CleverSwarm /login input
|
|
if _required_args[arg].__name__ == "OAuth2PasswordRequestForm":
|
|
try:
|
|
_verified_args[arg] = OAuth2PasswordRequestForm(**_form_data)
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
if self.authenticated_user_arg_name in _signature_args.keys():
|
|
_verified_args[self.authenticated_user_arg_name] = auth_user
|
|
|
|
return await self.call_cleverthis_api(
|
|
message,
|
|
_verified_args,
|
|
api_method=_route.endpoint,
|
|
success_code=_route.status_code if _route.status_code else 200,
|
|
)
|
|
|
|
return DataResponseFactory.of(
|
|
message.id(),
|
|
404,
|
|
"text/plain",
|
|
str(f"Destination location [{message.path()}] does not exists").encode("utf-8"),
|
|
message.trace_info(),
|
|
)
|
|
|
|
def _verify_and_instantiate_arguments(
|
|
self,
|
|
args: Any,
|
|
api_method: Callable[[Any, Any], Coroutine],
|
|
) -> dict:
|
|
"""
|
|
Check the provided arguments against the API method signature and convert the incoming string value
|
|
into correct expected input object.
|
|
The logic used FastAPI annotation and would use the value from 'default_factory' invocation (where defined)
|
|
or 'default' value, as defined in the FastAPI annotation, to create default value in case the object is not in
|
|
the input values provided by the caller.
|
|
|
|
"""
|
|
_verified_args = {}
|
|
try:
|
|
_sig = inspect.signature(api_method)
|
|
_required_args = list(_sig.parameters.values())
|
|
# _required_args = getattr(api_method, '__annotations__', {})
|
|
for arg in _required_args:
|
|
if arg.name in args:
|
|
_annotation = arg.annotation
|
|
_str_annotation = str(_annotation)
|
|
# convert to another type if arg is not a str
|
|
if arg.name == self.authenticated_user_arg_name:
|
|
_verified_args[arg.name] = args.get(arg.name)
|
|
elif _annotation == int:
|
|
_verified_args[arg.name] = int(args.get(arg.name))
|
|
elif _annotation == float:
|
|
_verified_args[arg.name] = float(args.get(arg.name))
|
|
elif _annotation == bool:
|
|
_verified_args[arg.name] = serializer.str_to_bool(args.get(arg.name))
|
|
elif (
|
|
"Optional" in _str_annotation
|
|
or _str_annotation.lower().startswith("list")
|
|
or _str_annotation.startswith("typing.List")
|
|
or "class" in _str_annotation
|
|
):
|
|
_type_descr = TypeDescriptor(
|
|
_str_annotation, extra_init_data=g_extra_init_data
|
|
)
|
|
try:
|
|
_payload = args.get(arg.name, "")
|
|
_verified_args[arg.name] = _type_descr.instantiate(
|
|
_payload, is_json=is_likely_json(_payload)
|
|
)
|
|
except Exception as err:
|
|
logging_error(
|
|
f"Could not instantiate argument {arg}, defaulting to its string value",
|
|
err,
|
|
)
|
|
_verified_args[arg.name] = args.get(arg.name, None)
|
|
else:
|
|
_temp = args.get(arg.name)
|
|
if isinstance(arg.annotation, enum.EnumType) or isinstance(
|
|
arg.annotation, enum.Enum
|
|
):
|
|
try:
|
|
_temp = arg.annotation[_temp]
|
|
except KeyError as ke:
|
|
# some enums have string value different to key spelling, try different thing
|
|
try:
|
|
_temp = arg.annotation(_temp)
|
|
except ValueError as ve:
|
|
logging_warning(
|
|
f"cannot convert '{_temp}' to Enum type {arg.annotation}, {ke}, {ve}"
|
|
)
|
|
print(f"ENUM -> {_temp}")
|
|
_verified_args[arg.name] = _temp
|
|
else:
|
|
if hasattr(arg, "default"):
|
|
_temp = arg.default
|
|
if hasattr(_temp, "default_factory") and _temp.default_factory is not None:
|
|
_temp = _temp.default_factory()
|
|
elif hasattr(_temp, "default"):
|
|
_temp = _temp.default
|
|
if not str(_temp) == "<class 'inspect._empty'>":
|
|
_verified_args[arg.name] = _temp
|
|
else:
|
|
logging_warning(
|
|
f"{api_method.__name__}: Missing required argument: {arg.name}"
|
|
)
|
|
except Exception as e:
|
|
logging_error("Failed to validate and instantiate input args", e)
|
|
|
|
return _verified_args
|
|
|
|
async def call_cleverthis_api(
|
|
self,
|
|
message: AMQMessage,
|
|
args: Any,
|
|
api_method: Callable[[Any, Any], Coroutine],
|
|
success_code: int,
|
|
) -> DataResponse:
|
|
"""
|
|
Handles POST and PUT requests, which typically contain possibly complex payload data,
|
|
passed as a dictionary. Invokes the provided API method with the given arguments and
|
|
authenticated user, and returns the response as DataResponse.
|
|
|
|
:param message: DataMessage containing the details of the call and the payload
|
|
:param args: map of the arguments to pass to the API method
|
|
:param api_method: Callable - the method to invoke. Last parameter is the authenticated user
|
|
:param success_code: HTTP code to be returned when operation suceeds
|
|
|
|
:return: DataResponse class
|
|
"""
|
|
try:
|
|
_verified_args = self._verify_and_instantiate_arguments(
|
|
args=args, api_method=api_method
|
|
)
|
|
logging_info(
|
|
"INVOKE ENDPOINT: [%s], ARGS: {%s}",
|
|
api_method.__name__,
|
|
", ".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)}")
|
|
|
|
if isinstance(_result, FileResponse):
|
|
_json_result, _content_type = (
|
|
await _convert_file_response(
|
|
_result,
|
|
message.connection,
|
|
file_handler=StreamingFileHandler(),
|
|
loop=self.loop,
|
|
),
|
|
"application/octet-stream",
|
|
)
|
|
elif isinstance(_result, BaseModel):
|
|
_json_result = _result.json()
|
|
_content_type = "application/json"
|
|
else:
|
|
if isinstance(_result, str):
|
|
_json_result = _result
|
|
else:
|
|
_json_result = python_type_to_json(_result)
|
|
_content_type = "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)
|
|
)
|
|
)
|
|
logging_info(
|
|
f"ALL DONE in call_cleverthis_api id:{message.id()}, resp code:{success_code}"
|
|
)
|
|
return DataResponseFactory.of(
|
|
message.id(),
|
|
success_code,
|
|
_content_type,
|
|
_binary_result,
|
|
message.trace_info(),
|
|
)
|
|
except HTTPException as http_error:
|
|
logging_error(f"Service REST endpoint invocation failed: {http_error}")
|
|
_content = str(http_error.detail)
|
|
if not is_likely_json(_content):
|
|
return DataResponseFactory.of_error_message(
|
|
message.id(),
|
|
AMQErrorMessage(
|
|
http_error.status_code,
|
|
"Service Invocation Failed",
|
|
_content,
|
|
),
|
|
message.trace_info(),
|
|
)
|
|
return DataResponseFactory.of(
|
|
message.id(),
|
|
http_error.status_code,
|
|
"application/json",
|
|
_content.encode("utf-8"),
|
|
message.trace_info(),
|
|
)
|
|
except Exception as error:
|
|
logging_error(f"Service endpoint invocation failed: {error}")
|
|
_content = str(error)
|
|
if not is_likely_json(_content):
|
|
return DataResponseFactory.of_error_message(
|
|
message.id(),
|
|
AMQErrorMessage(
|
|
500,
|
|
"Service Invocation Failed",
|
|
_content,
|
|
),
|
|
message.trace_info(),
|
|
)
|
|
return DataResponseFactory.of(
|
|
message.id(),
|
|
500,
|
|
"application/json",
|
|
_content.encode("utf-8"),
|
|
message.trace_info(),
|
|
)
|