clan-app: Generalize architecture for API requests
This commit is contained in:
87
pkgs/clan-app/clan_app/api/api_bridge.py
Normal file
87
pkgs/clan-app/clan_app/api/api_bridge.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .middleware import Middleware
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackendRequest:
|
||||
method_name: str
|
||||
args: dict[str, Any]
|
||||
header: dict[str, Any]
|
||||
op_key: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackendResponse:
|
||||
body: Any
|
||||
header: dict[str, Any]
|
||||
_op_key: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiBridge(ABC):
|
||||
"""Generic interface for API bridges that can handle method calls from different sources."""
|
||||
|
||||
middleware_chain: tuple["Middleware", ...]
|
||||
|
||||
@abstractmethod
|
||||
def send_response(self, response: BackendResponse) -> None:
|
||||
"""Send response back to the client."""
|
||||
|
||||
def process_request(self, request: BackendRequest) -> None:
|
||||
"""Process an API request through the middleware chain."""
|
||||
from .middleware import MiddlewareContext
|
||||
|
||||
with ExitStack() as stack:
|
||||
context = MiddlewareContext(
|
||||
request=request,
|
||||
bridge=self,
|
||||
exit_stack=stack,
|
||||
)
|
||||
|
||||
# Process through middleware chain
|
||||
for middleware in self.middleware_chain:
|
||||
try:
|
||||
log.debug(
|
||||
f"{middleware.__class__.__name__} => {request.method_name}"
|
||||
)
|
||||
middleware.process(context)
|
||||
except Exception as e:
|
||||
# If middleware fails, handle error
|
||||
self.send_error_response(
|
||||
request.op_key, str(e), ["middleware_error"]
|
||||
)
|
||||
return
|
||||
|
||||
def send_error_response(
|
||||
self, op_key: str, error_message: str, location: list[str]
|
||||
) -> None:
|
||||
"""Send an error response."""
|
||||
from clan_lib.api import ApiError, ErrorDataClass
|
||||
|
||||
error_data = ErrorDataClass(
|
||||
op_key=op_key,
|
||||
status="error",
|
||||
errors=[
|
||||
ApiError(
|
||||
message="An internal error occured",
|
||||
description=error_message,
|
||||
location=location,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
response = BackendResponse(
|
||||
body=error_data,
|
||||
header={},
|
||||
_op_key=op_key,
|
||||
)
|
||||
|
||||
self.send_response(response)
|
||||
14
pkgs/clan-app/clan_app/api/middleware/__init__.py
Normal file
14
pkgs/clan-app/clan_app/api/middleware/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Middleware components for the webview API bridge."""
|
||||
|
||||
from .argument_parsing import ArgumentParsingMiddleware
|
||||
from .base import Middleware, MiddlewareContext
|
||||
from .logging import LoggingMiddleware
|
||||
from .method_execution import MethodExecutionMiddleware
|
||||
|
||||
__all__ = [
|
||||
"ArgumentParsingMiddleware",
|
||||
"LoggingMiddleware",
|
||||
"MethodExecutionMiddleware",
|
||||
"Middleware",
|
||||
"MiddlewareContext",
|
||||
]
|
||||
55
pkgs/clan-app/clan_app/api/middleware/argument_parsing.py
Normal file
55
pkgs/clan-app/clan_app/api/middleware/argument_parsing.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from clan_lib.api import MethodRegistry, from_dict
|
||||
|
||||
from clan_app.api.api_bridge import BackendRequest
|
||||
|
||||
from .base import Middleware, MiddlewareContext
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArgumentParsingMiddleware(Middleware):
|
||||
"""Middleware that handles argument parsing and dataclass construction."""
|
||||
|
||||
api: MethodRegistry
|
||||
|
||||
def process(self, context: MiddlewareContext) -> None:
|
||||
try:
|
||||
# Convert dictionary arguments to dataclass instances
|
||||
reconciled_arguments = {}
|
||||
for k, v in context.request.args.items():
|
||||
if k == "op_key":
|
||||
continue
|
||||
|
||||
# Get the expected argument type from the API
|
||||
arg_class = self.api.get_method_argtype(context.request.method_name, k)
|
||||
|
||||
# Convert dictionary to dataclass instance
|
||||
reconciled_arguments[k] = from_dict(arg_class, v)
|
||||
|
||||
# Add op_key to arguments
|
||||
reconciled_arguments["op_key"] = context.request.op_key
|
||||
|
||||
# Create a new request with reconciled arguments
|
||||
|
||||
updated_request = BackendRequest(
|
||||
method_name=context.request.method_name,
|
||||
args=reconciled_arguments,
|
||||
header=context.request.header,
|
||||
op_key=context.request.op_key,
|
||||
)
|
||||
context.request = updated_request
|
||||
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error while parsing arguments for {context.request.method_name}"
|
||||
)
|
||||
context.bridge.send_error_response(
|
||||
context.request.op_key,
|
||||
str(e),
|
||||
["argument_parsing", context.request.method_name],
|
||||
)
|
||||
raise
|
||||
29
pkgs/clan-app/clan_app/api/middleware/base.py
Normal file
29
pkgs/clan-app/clan_app/api/middleware/base.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import AbstractContextManager, ExitStack
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from clan_app.api.api_bridge import ApiBridge, BackendRequest
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiddlewareContext:
|
||||
request: "BackendRequest"
|
||||
bridge: "ApiBridge"
|
||||
exit_stack: ExitStack
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Middleware(ABC):
|
||||
"""Abstract base class for middleware components."""
|
||||
|
||||
@abstractmethod
|
||||
def process(self, context: MiddlewareContext) -> None:
|
||||
"""Process the request through this middleware."""
|
||||
|
||||
def register_context_manager(
|
||||
self, context: MiddlewareContext, cm: AbstractContextManager[Any]
|
||||
) -> Any:
|
||||
"""Register a context manager with the exit stack."""
|
||||
return context.exit_stack.enter_context(cm)
|
||||
99
pkgs/clan-app/clan_app/api/middleware/logging.py
Normal file
99
pkgs/clan-app/clan_app/api/middleware/logging.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import io
|
||||
import logging
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from clan_lib.async_run import AsyncContext, get_async_ctx, set_async_ctx
|
||||
from clan_lib.custom_logger import RegisteredHandler, setup_logging
|
||||
from clan_lib.log_manager import LogManager
|
||||
|
||||
from .base import Middleware, MiddlewareContext
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoggingMiddleware(Middleware):
|
||||
"""Middleware that sets up logging context without executing methods."""
|
||||
|
||||
log_manager: LogManager
|
||||
|
||||
def process(self, context: MiddlewareContext) -> None:
|
||||
method = context.request.method_name
|
||||
|
||||
try:
|
||||
# Handle log group configuration
|
||||
log_group: list[str] | None = context.request.header.get("logging", {}).get(
|
||||
"group_path", None
|
||||
)
|
||||
if log_group is not None:
|
||||
if not isinstance(log_group, list):
|
||||
msg = f"Expected log_group to be a list, got {type(log_group)}"
|
||||
raise TypeError(msg)
|
||||
log.warning(
|
||||
f"Using log group {log_group} for {context.request.method_name} with op_key {context.request.op_key}"
|
||||
)
|
||||
# Create log file
|
||||
log_file = self.log_manager.create_log_file(
|
||||
method, op_key=context.request.op_key, group_path=log_group
|
||||
).get_file_path()
|
||||
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error while handling request header of {context.request.method_name}"
|
||||
)
|
||||
context.bridge.send_error_response(
|
||||
context.request.op_key,
|
||||
str(e),
|
||||
["header_middleware", context.request.method_name],
|
||||
)
|
||||
return
|
||||
|
||||
# Register logging context manager
|
||||
class LoggingContextManager:
|
||||
def __init__(self, log_file: Any) -> None:
|
||||
self.log_file = log_file
|
||||
self.log_f: Any = None
|
||||
self.handler: RegisteredHandler | None = None
|
||||
self.original_ctx: AsyncContext | None = None
|
||||
|
||||
def __enter__(self) -> "LoggingContextManager":
|
||||
self.log_f = self.log_file.open("ab")
|
||||
self.original_ctx = get_async_ctx()
|
||||
|
||||
# Set up async context for logging
|
||||
ctx = AsyncContext(**self.original_ctx.__dict__)
|
||||
ctx.stderr = self.log_f
|
||||
ctx.stdout = self.log_f
|
||||
set_async_ctx(ctx)
|
||||
|
||||
# Set up logging handler
|
||||
handler_stream = io.TextIOWrapper(
|
||||
self.log_f, # type: ignore[arg-type]
|
||||
encoding="utf-8",
|
||||
write_through=True,
|
||||
line_buffering=True,
|
||||
)
|
||||
self.handler = setup_logging(
|
||||
log.getEffectiveLevel(), log_file=handler_stream
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: types.TracebackType | None,
|
||||
) -> None:
|
||||
if self.handler:
|
||||
self.handler.root_logger.removeHandler(self.handler.new_handler)
|
||||
self.handler.new_handler.close()
|
||||
if self.log_f:
|
||||
self.log_f.close()
|
||||
if self.original_ctx:
|
||||
set_async_ctx(self.original_ctx)
|
||||
|
||||
# Register the logging context manager
|
||||
self.register_context_manager(context, LoggingContextManager(log_file))
|
||||
41
pkgs/clan-app/clan_app/api/middleware/method_execution.py
Normal file
41
pkgs/clan-app/clan_app/api/middleware/method_execution.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from clan_lib.api import MethodRegistry
|
||||
|
||||
from clan_app.api.api_bridge import BackendResponse
|
||||
|
||||
from .base import Middleware, MiddlewareContext
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MethodExecutionMiddleware(Middleware):
|
||||
"""Middleware that handles actual method execution."""
|
||||
|
||||
api: MethodRegistry
|
||||
|
||||
def process(self, context: MiddlewareContext) -> None:
|
||||
method = self.api.functions[context.request.method_name]
|
||||
|
||||
try:
|
||||
# Execute the actual method
|
||||
result = method(**context.request.args)
|
||||
|
||||
response = BackendResponse(
|
||||
body=result,
|
||||
header={},
|
||||
_op_key=context.request.op_key,
|
||||
)
|
||||
context.bridge.send_response(response)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error while handling result of {context.request.method_name}"
|
||||
)
|
||||
context.bridge.send_error_response(
|
||||
context.request.op_key,
|
||||
str(e),
|
||||
["method_execution", context.request.method_name],
|
||||
)
|
||||
Reference in New Issue
Block a user