from __future__ import annotations import inspect from collections.abc import Awaitable, Callable from typing import Any, ClassVar from plain.http import ( NotAllowedResponse, Request, Response, ) from plain.logs import get_framework_logger, log_exception from .exceptions import ResponseException logger = get_framework_logger("plain.request") # TRACE is an XST-adjacent debugging verb, CONNECT is a proxy concept — # neither belongs in an application view. OPTIONS is provided by the base # directly; HEAD falls back to GET at dispatch time. _HANDLER_NAMES = ("get", "post", "put", "patch", "delete", "head") class View[HandlerResult = Response]: request: Request url_kwargs: dict[str, Any] implemented_methods: ClassVar[frozenset[str]] = frozenset() def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) cls.implemented_methods = frozenset( name for name in _HANDLER_NAMES if getattr(cls, name, None) is not getattr(View, name, None) ) def get(self) -> HandlerResult: raise NotImplementedError def post(self) -> HandlerResult: raise NotImplementedError def put(self) -> HandlerResult: raise NotImplementedError def patch(self) -> HandlerResult: raise NotImplementedError def delete(self) -> HandlerResult: raise NotImplementedError def head(self) -> HandlerResult: raise NotImplementedError def __init__( self, *, request: Request, url_kwargs: dict[str, Any] | None = None, ) -> None: self.request = request self.url_kwargs = url_kwargs or {} def get_request_handler(self) -> Callable[[], Any] | None: """Return the handler for the current request method. HEAD falls back to `get` when no explicit `head` handler is defined, per HTTP semantics (HEAD == GET without a response body). The body is stripped at the transport layer, not here. """ if not self.request.method: raise AttributeError("HTTP method is not set") if self.request.method == "OPTIONS": return self.options name = self.request.method.lower() if name in self.implemented_methods: return getattr(self, name) if self.request.method == "HEAD" and "get" in self.implemented_methods: return self.get return None def before_request(self) -> None: """Pre-dispatch hook. Raise to reject the request.""" def after_response(self, response: Response) -> Response: """Post-dispatch hook. Runs for every response — successes, errors, 405s. Return the response (possibly mutated or replaced). Exceptions raised here escape to the framework error renderer — they are not routed through `handle_exception`. """ return response def handle_exception(self, exc: Exception) -> Response: """Translate a raised exception into a response. Re-raise to defer to the framework default. Returning a 4xx response treats the exception as a handled outcome (e.g. ValidationError → 400) — no logging, no exception attachment. Returning a 5xx response is treated as a real failure: the framework attaches `response.exception` and calls `log_exception` for you, so observability tooling can record it from the response. Re-raising escapes to the framework error renderer, which logs and renders `{status}.html`. """ raise exc def get_response(self) -> Response: try: self.before_request() handler = self.get_request_handler() if not handler: logger.warning( "Method not allowed", extra={ "method": self.request.method, "path": self.request.path, "status_code": 405, }, ) response: Response = NotAllowedResponse(self._allowed_methods()) elif inspect.iscoroutinefunction(handler): return self._dispatch_handler_async(handler) # ty: ignore[invalid-return-type] else: response = self.convert_result_to_response(handler()) except Exception as e: response = self._respond_to_exception(e) return self.after_response(response) async def _dispatch_handler_async( self, handler: Callable[[], Awaitable[Response]] ) -> Response: try: result = await handler() response = self.convert_result_to_response(result) except Exception as e: response = self._respond_to_exception(e) return self.after_response(response) def _respond_to_exception(self, exc: Exception) -> Response: if isinstance(exc, ResponseException): return exc.response response = self.handle_exception(exc) # 5xx responses from handle_exception represent a real failure that # the view chose to render itself. Stamp the response with the # exception and log centrally so subclasses don't each have to # remember (and so the canonical OTel SERVER span can record it via # `_finalize_span`). if response.status_code >= 500: log_exception(self.request, exc) response.exception = exc return response def convert_result_to_response(self, result: HandlerResult) -> Response: """Hook for subclasses (e.g. `APIView`) to accept shorthand return types.""" if isinstance(result, Response): return result raise TypeError( f"{type(self).__name__} handlers must return a Response " f"(got {type(result).__name__}). " "Wrap raw data in a Response/JsonResponse, or use APIView for " "dict/list/tuple shorthand returns." ) def options(self) -> Response: """Handle responding to requests for the OPTIONS HTTP verb.""" response = Response() response.headers["Allow"] = ", ".join(self._allowed_methods()) response.headers["Content-Length"] = "0" return response def _allowed_methods(self) -> list[str]: methods = {m.upper() for m in self.implemented_methods} if "GET" in methods: methods.add("HEAD") methods.add("OPTIONS") return sorted(methods)