diff --git a/core_lib/session/user_security.py b/core_lib/session/user_security.py index 0ace9af5..367bd081 100644 --- a/core_lib/session/user_security.py +++ b/core_lib/session/user_security.py @@ -38,9 +38,13 @@ def token_to_session_object(self, token): def _secure_entry(self, request, policies): cookies = {} - if WebHelpersUtils.get_server_type() == WebHelpersUtils.ServerType.DJANGO: + server_type = WebHelpersUtils.get_server_type() + if server_type == WebHelpersUtils.ServerType.DJANGO: cookies = request.COOKIES - elif WebHelpersUtils.get_server_type() == WebHelpersUtils.ServerType.FLASK: + elif server_type == WebHelpersUtils.ServerType.FLASK: + cookies = request.cookies + elif server_type == WebHelpersUtils.ServerType.FASTAPI: + # Starlette's `Request.cookies` is a plain dict-like. cookies = request.cookies token = cookies.get(self.cookie_name) session_obj = self.from_session_data(self.token_handler.decode(token)) if token else None diff --git a/core_lib/web_helpers/decorators.py b/core_lib/web_helpers/decorators.py index ef8c3f49..eba01194 100644 --- a/core_lib/web_helpers/decorators.py +++ b/core_lib/web_helpers/decorators.py @@ -36,6 +36,23 @@ def _get_request(): return None except Exception as e: logger.debug(f"Unable to determine server type: {e}") +<<<<<<< Updated upstream +======= + return None + + if server_type == WebHelpersUtils.ServerType.FLASK: + try: + from flask import request as flask_request + return flask_request + except Exception as e: + logger.debug(f"Failed to fetch Flask request: {e}") + return None + # DJANGO / FASTAPI / unknown: no thread-local request available — both + # bind the request per-task (per-view for Django, per-async-task for + # FastAPI / Starlette). Callers should attach the request to the error + # middleware context explicitly. + return None +>>>>>>> Stashed changes def _execute_error_middlewares(exc, func): request = _get_request() diff --git a/core_lib/web_helpers/fastapi/__init__.py b/core_lib/web_helpers/fastapi/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core_lib/web_helpers/fastapi/require_login.py b/core_lib/web_helpers/fastapi/require_login.py new file mode 100644 index 00000000..4da48dbf --- /dev/null +++ b/core_lib/web_helpers/fastapi/require_login.py @@ -0,0 +1,30 @@ +"""@RequireLogin decorator for FastAPI views. + +Mirrors the Flask / Django variants. FastAPI views can be async or sync; +this decorator supports both. The wrapped view must accept the Starlette +``Request`` as its first parameter (the standard FastAPI convention via +``request: Request`` parameter binding). +""" +import logging +from functools import wraps + +from core_lib.web_helpers.require_login_helper import require_login + +logger = logging.getLogger(__name__) + + +class RequireLogin(object): + def __init__(self, policies=None): + if policies is None: + policies = [] + self.policies = policies + + def __call__(self, func, *args, **kwargs): + @wraps(func) + def __wrapper(request, *args, **kwargs): + # `require_login` itself re-passes `request` to `func` when the + # server type is FASTAPI (or DJANGO), so we strip it from + # *args here and only forward the rest. + return require_login(request, self.policies, func, *args, **kwargs) + + return __wrapper diff --git a/core_lib/web_helpers/fastapi/user_auth_middleware.py b/core_lib/web_helpers/fastapi/user_auth_middleware.py new file mode 100644 index 00000000..525e62ef --- /dev/null +++ b/core_lib/web_helpers/fastapi/user_auth_middleware.py @@ -0,0 +1,28 @@ +"""ASGI user-auth middleware for FastAPI / Starlette. + +Mirrors the Flask `UserAuthMiddleware` (WSGI) and Django `UserAuthMiddleware` +patterns: read the configured cookie, decode it via the registered +SecurityHandler, and stash the resulting session object on the request +scope so views / dependencies can read it. +""" +from starlette.middleware.base import BaseHTTPMiddleware + +from core_lib.session.security_handler import SecurityHandler + + +class UserAuthMiddleware(BaseHTTPMiddleware): + """Attach the decoded session object to ``request.state.user`` when + the configured cookie is present. Use it like any Starlette middleware:: + + app.add_middleware(UserAuthMiddleware, cookie_name='my_cookie') + """ + + def __init__(self, app, cookie_name: str): + super().__init__(app) + self.cookie_name = cookie_name + + async def dispatch(self, request, call_next): + token = request.cookies.get(self.cookie_name) + if token: + request.state.user = SecurityHandler.get().token_to_session_object(token) + return await call_next(request) diff --git a/core_lib/web_helpers/request_response_helpers.py b/core_lib/web_helpers/request_response_helpers.py index aeefd430..d939b2a6 100644 --- a/core_lib/web_helpers/request_response_helpers.py +++ b/core_lib/web_helpers/request_response_helpers.py @@ -11,6 +11,7 @@ from django.http import HttpResponse from flask import Flask +from starlette.responses import Response as StarletteResponse def response_status(status: int = HTTPStatus.OK.value): @@ -57,6 +58,8 @@ def generate_response(data, status, media_type: MediaType = MediaType.TEXT_HTML, return generate_response_django(data, status, media_type, headers) elif WebHelpersUtils.get_server_type() == WebHelpersUtils.ServerType.FLASK: return generate_response_flask(data, status, media_type, headers) + elif WebHelpersUtils.get_server_type() == WebHelpersUtils.ServerType.FASTAPI: + return generate_response_fastapi(data, status, media_type, headers) def generate_response_django(data, status, media_type: MediaType, headers: dict = {}): @@ -73,6 +76,23 @@ def generate_response_flask(data, status, media_type: MediaType, headers: dict = return response +def generate_response_fastapi(data, status, media_type: MediaType, headers: dict = None): + # Starlette Response — works for FastAPI views and middlewares. + # FastAPI accepts these directly as view return values. + if headers is None: + headers = {} + # IntEnum compatibility — Starlette accepts int but `status_code` is + # explicitly typed int and HTTPStatus members satisfy that. + response = StarletteResponse( + content=data, + status_code=int(status), + media_type=media_type.value, + ) + for key, value in headers.items(): + response.headers[key] = value + return response + + # # HELPERS # @@ -83,3 +103,12 @@ def request_body_dict(request): return json.loads(request.body.decode('utf-8')) elif WebHelpersUtils.get_server_type() == WebHelpersUtils.ServerType.FLASK: return request.json + elif WebHelpersUtils.get_server_type() == WebHelpersUtils.ServerType.FASTAPI: + # FastAPI's normal pattern is `await request.json()` inside an async + # view. Inside this sync helper we expect callers to have pre-read + # the body (e.g. via a sync wrapper, or by passing `request.json()` + # already-resolved). Accept both shapes for ergonomics. + body = request.body if isinstance(request.body, (bytes, bytearray)) else None + if body is not None: + return json.loads(body.decode('utf-8')) + return None diff --git a/core_lib/web_helpers/require_login_helper.py b/core_lib/web_helpers/require_login_helper.py index 25ba8c80..642cbcc3 100644 --- a/core_lib/web_helpers/require_login_helper.py +++ b/core_lib/web_helpers/require_login_helper.py @@ -9,6 +9,7 @@ def require_login(request, policies, func, *args, **kwargs): response = handle_exception(SecurityHandler.get()._secure_entry, request, policies) if not response: +<<<<<<< Updated upstream try: if WebHelpersUtils.get_server_type() == WebHelpersUtils.ServerType.DJANGO: return func(request, *args, **kwargs) @@ -18,4 +19,20 @@ def require_login(request, policies, func, *args, **kwargs): logger.error( f'error while loading target page for controller entry name `{func.__name__}`', exc_info=True ) +======= + # Route the view function through handle_exception so its exceptions + # become proper HTTP responses (rather than being silently swallowed + # and returning None — which previously rendered as a blank page). + # Django and FastAPI views receive the request as a positional arg + # (Django convention; FastAPI via this lib's RequireLogin wrapper). + # Flask views read the request from a thread-local proxy so it isn't + # passed positionally. + server_type = WebHelpersUtils.get_server_type() + if server_type in ( + WebHelpersUtils.ServerType.DJANGO, + WebHelpersUtils.ServerType.FASTAPI, + ): + return handle_exception(func, request, *args, **kwargs) + return handle_exception(func, *args, **kwargs) +>>>>>>> Stashed changes return response diff --git a/core_lib/web_helpers/web_helprs_utils.py b/core_lib/web_helpers/web_helprs_utils.py index 8ac865cc..051f1734 100644 --- a/core_lib/web_helpers/web_helprs_utils.py +++ b/core_lib/web_helpers/web_helprs_utils.py @@ -8,6 +8,7 @@ class WebHelpersUtils(object): class ServerType(enum.Enum): FLASK = 'flask' DJANGO = 'django' + FASTAPI = 'fastapi' @staticmethod def init(server_type: ServerType): diff --git a/tests/test_web_helpers_fastapi.py b/tests/test_web_helpers_fastapi.py new file mode 100644 index 00000000..ace283c6 --- /dev/null +++ b/tests/test_web_helpers_fastapi.py @@ -0,0 +1,243 @@ +"""Tests for FastAPI integration in core_lib/web_helpers.""" +import json +import unittest +from unittest.mock import MagicMock + +from core_lib.helpers.constants import MediaType +from core_lib.session.security_handler import SecurityHandler +from core_lib.web_helpers.fastapi.require_login import RequireLogin +from core_lib.web_helpers.fastapi.user_auth_middleware import UserAuthMiddleware +from core_lib.web_helpers.request_response_helpers import ( + generate_response, + generate_response_fastapi, + request_body_dict, +) +from core_lib.web_helpers.web_helprs_utils import WebHelpersUtils + + +class _FastAPIServerTypeFixture(unittest.TestCase): + """Switch the global server type to FASTAPI for the duration of the test + and restore the previous value afterwards (other test files use FLASK / + DJANGO and share global state).""" + + def setUp(self): + self._previous_server_type = WebHelpersUtils.server_type + self._previous_security = SecurityHandler.user_security + WebHelpersUtils.init(WebHelpersUtils.ServerType.FASTAPI) + + def tearDown(self): + WebHelpersUtils.server_type = self._previous_server_type + SecurityHandler.user_security = self._previous_security + + +# ── ServerType enum ───────────────────────────────────────────────────── + + +class TestFastapiServerType(unittest.TestCase): + def test_fastapi_added_to_enum(self): + self.assertEqual(WebHelpersUtils.ServerType.FASTAPI.value, 'fastapi') + + +# ── generate_response_fastapi ──────────────────────────────────────── + + +class TestGenerateResponseFastapi(_FastAPIServerTypeFixture): + def test_routed_via_generate_response(self): + # generate_response (the dispatcher) routes to the fastapi backend + # when WebHelpersUtils is initialized to FASTAPI. + resp = generate_response({'k': 'v'}, 200, MediaType.APPLICATION_JSON) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.body, json.dumps({'k': 'v'}).encode()) + + def test_text_html_response(self): + resp = generate_response_fastapi(b'

hi

', 200, MediaType.TEXT_HTML) + self.assertEqual(resp.status_code, 200) + self.assertIn('text/html', resp.headers.get('content-type', '')) + self.assertEqual(resp.body, b'

hi

') + + def test_default_headers_none(self): + # Mutable-default protection: headers=None must yield an empty dict. + resp = generate_response_fastapi(b'd', 200, MediaType.TEXT_HTML) + self.assertEqual(resp.status_code, 200) + + def test_extra_headers_applied(self): + resp = generate_response_fastapi( + b'd', 200, MediaType.TEXT_HTML, headers={'X-Custom': 'yes'} + ) + self.assertEqual(resp.headers['X-Custom'], 'yes') + + def test_accepts_int_enum_status(self): + from http import HTTPStatus + resp = generate_response_fastapi(b'', HTTPStatus.NOT_FOUND, MediaType.TEXT_HTML) + self.assertEqual(resp.status_code, 404) + + +# ── request_body_dict for FastAPI ──────────────────────────────────── + + +class TestRequestBodyDictFastapi(_FastAPIServerTypeFixture): + def test_bytes_body_parsed_as_json(self): + request = MagicMock() + request.body = b'{"a": 1}' + self.assertEqual(request_body_dict(request), {'a': 1}) + + def test_non_bytes_body_returns_none(self): + # Callers haven't pre-read the body — we don't have an event loop + # here, so just return None rather than raising. + request = MagicMock() + request.body = MagicMock() # not bytes/bytearray + self.assertIsNone(request_body_dict(request)) + + +# ── UserAuthMiddleware ─────────────────────────────────────────────── + + +class TestUserAuthMiddlewareFastapi(_FastAPIServerTypeFixture): + def test_attaches_user_to_request_state(self): + from fastapi import FastAPI, Request + from fastapi.testclient import TestClient + + # Replace SecurityHandler with a mock that returns a known session + mock_us = MagicMock() + mock_us.token_to_session_object.return_value = {'id': 42} + SecurityHandler.user_security = mock_us + + app = FastAPI() + app.add_middleware(UserAuthMiddleware, cookie_name='my_cookie') + + @app.get('/who') + def who(request: Request): + user = getattr(request.state, 'user', None) + return {'user': user} + + client = TestClient(app) + client.cookies.set('my_cookie', 'opaque-token') + r = client.get('/who') + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json(), {'user': {'id': 42}}) + mock_us.token_to_session_object.assert_called_once_with('opaque-token') + + def test_no_cookie_means_no_user_attached(self): + from fastapi import FastAPI, Request + from fastapi.testclient import TestClient + + mock_us = MagicMock() + SecurityHandler.user_security = mock_us + + app = FastAPI() + app.add_middleware(UserAuthMiddleware, cookie_name='my_cookie') + + @app.get('/who') + def who(request: Request): + return {'has_user': hasattr(request.state, 'user')} + + client = TestClient(app) + r = client.get('/who') + self.assertEqual(r.json(), {'has_user': False}) + mock_us.token_to_session_object.assert_not_called() + + +# ── RequireLogin decorator ─────────────────────────────────────────── + + +class TestRequireLoginFastapi(_FastAPIServerTypeFixture): + def setUp(self): + super().setUp() + # Install a mock SecurityHandler that approves all requests by + # default (returns None from _secure_entry → "pass"). + mock_us = MagicMock() + mock_us._secure_entry = MagicMock(return_value=None) + SecurityHandler.user_security = mock_us + + def test_passes_request_to_view(self): + captured = {} + + @RequireLogin(policies=[]) + def view(request): + captured['request'] = request + from starlette.responses import Response + return Response(content=b'ok', status_code=200) + + fake_request = MagicMock() + fake_request.cookies = {} + result = view(fake_request) + self.assertEqual(result.status_code, 200) + self.assertIs(captured['request'], fake_request) + + def test_unauthorized_response_short_circuits_view(self): + # _secure_entry returns a truthy response → view is NOT called + from starlette.responses import Response + unauthorized = Response(content=b'no', status_code=401) + SecurityHandler.user_security._secure_entry = MagicMock( + return_value=unauthorized + ) + + view_calls = [] + + @RequireLogin(policies=[]) + def view(request): + view_calls.append(1) + + result = view(MagicMock()) + self.assertIs(result, unauthorized) + self.assertEqual(view_calls, []) + + def test_view_exception_yields_500(self): + # Bug 16 regression for the FastAPI path — view exceptions become + # proper 500 responses, not None. + from starlette.responses import Response + + @RequireLogin(policies=[]) + def view(request): + raise RuntimeError('boom') + + result = view(MagicMock()) + self.assertEqual(result.status_code, 500) + + def test_default_policies_empty(self): + rl = RequireLogin() + self.assertEqual(rl.policies, []) + + +# ── _secure_entry FastAPI cookie branch ───────────────────────────── + + +class TestSecureEntryFastapi(_FastAPIServerTypeFixture): + def test_token_extracted_from_request_cookies(self): + from core_lib.session.user_security import UserSecurity + from core_lib.session.token_handler import TokenHandler + + captured = {} + + class TH(TokenHandler): + def encode(self, m): return '' + def decode(self, e): + return {'sub': e} + + class US(UserSecurity): + def secure_entry(self, request, session_obj, policies): + captured['session_obj'] = session_obj + return 'ok' + def from_session_data(self, session_data): + return {'from': session_data} + def generate_session_data(self, obj): + return obj + + us = US('my_cookie', TH()) + request = MagicMock() + request.cookies = {'my_cookie': 'tok-value'} + result = us._secure_entry(request, []) + self.assertEqual(result, 'ok') + self.assertEqual(captured['session_obj'], {'from': {'sub': 'tok-value'}}) + + +# ── _get_request returns None on FastAPI ──────────────────────────── + + +class TestGetRequestFastapi(_FastAPIServerTypeFixture): + def test_returns_none(self): + # FastAPI binds the request per-async-task; there's no thread-local + # proxy. _get_request returns None and callers attach the request + # via the error middleware context instead. + from core_lib.web_helpers.decorators import _get_request + self.assertIsNone(_get_request())