From 6d4c456e7fcbe713288bf86cc3883c794261885f Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 22 Jun 2022 00:53:36 -0700 Subject: [PATCH] idea on how sessions might be implemented --- src/idom/backend/sanic.py | 63 ++++++++++++++++++++++++++++++++------- src/idom/backend/types.py | 16 ++++++++-- src/idom/backend/utils.py | 51 +++++++++++++++++++++++++++++-- 3 files changed, 115 insertions(+), 15 deletions(-) diff --git a/src/idom/backend/sanic.py b/src/idom/backend/sanic.py index 537ed839f..ea7a9057b 100644 --- a/src/idom/backend/sanic.py +++ b/src/idom/backend/sanic.py @@ -3,7 +3,7 @@ import asyncio import json import logging -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any, Dict, Tuple, Union from urllib import parse as urllib_parse from uuid import uuid4 @@ -14,7 +14,7 @@ from sanic_cors import CORS from websockets.legacy.protocol import WebSocketCommonProtocol -from idom.backend.types import Location +from idom.backend.types import Location, SessionState from idom.core.hooks import Context, create_context, use_context from idom.core.layout import Layout, LayoutEvent from idom.core.serve import ( @@ -27,7 +27,12 @@ from idom.core.types import RootComponentConstructor from ._asgi import serve_development_asgi -from .utils import safe_client_build_dir_path, safe_web_modules_dir_path +from .utils import ( + SESSION_COOKIE_NAME, + SessionManager, + safe_client_build_dir_path, + safe_web_modules_dir_path, +) logger = logging.getLogger(__name__) @@ -47,7 +52,7 @@ def configure( _setup_common_routes(blueprint, options) # this route should take priority so set up it up first - _setup_single_view_dispatcher_route(blueprint, component) + _setup_single_view_dispatcher_route(blueprint, component, options) app.blueprint(blueprint) @@ -129,6 +134,9 @@ class Options: url_prefix: str = "" """The URL prefix where IDOM resources will be served from""" + session_manager: SessionManager[Any] | None = None + """Used to create session cookies to perserve client state""" + def _setup_common_routes(blueprint: Blueprint, options: Options) -> None: cors_options = options.cors @@ -164,22 +172,55 @@ async def web_module_files( def _setup_single_view_dispatcher_route( - blueprint: Blueprint, constructor: RootComponentConstructor + blueprint: Blueprint, + constructor: RootComponentConstructor, + options: Options, ) -> None: async def model_stream( request: request.Request, socket: WebSocketCommonProtocol, path: str = "" ) -> None: + root = ConnectionContext(constructor(), value=Connection(request, socket, path)) + + if options.session_manager: + root = options.session_manager.context( + root, value=request.ctx.idom_sesssion_state + ) + send, recv = _make_send_recv_callbacks(socket) - conn = Connection(request, socket, path) - await serve_json_patch( - Layout(ConnectionContext(constructor(), value=conn)), - send, - recv, - ) + await serve_json_patch(Layout(root), send, recv) blueprint.add_websocket_route(model_stream, "/_api/stream") blueprint.add_websocket_route(model_stream, "//_api/stream") + if options.session_manager: + smgr = options.session_manager + + @blueprint.on_request + async def set_session_on_request(request: request.Request) -> None: + if request.scheme not in ("http", "https"): + return + session_id = request.cookies.get(SESSION_COOKIE_NAME) + request.ctx.idom_session_state = await smgr.get_state(session_id) + + @blueprint.on_response + async def set_session_cookie_header( + request: request.Request, response: response.ResponseStream + ): + session_state: SessionState[Any] = request.ctx.idom_session_state + # only set cookie if it has not been set before + if session_state.fresh: + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie + response.cookies[SESSION_COOKIE_NAME] = session_state.id + response.cookies[SESSION_COOKIE_NAME]["secure"] = True + response.cookies[SESSION_COOKIE_NAME]["httponly"] = True + response.cookies[SESSION_COOKIE_NAME]["samesite"] = "strict" + response.cookies[SESSION_COOKIE_NAME][ + "expires" + ] = session_state.expiry_date + + await smgr.update_state(replace(session_state, fresh=False)) + logger.info(f"Setting cookie {response.cookies[SESSION_COOKIE_NAME]}") + def _make_send_recv_callbacks( socket: WebSocketCommonProtocol, diff --git a/src/idom/backend/types.py b/src/idom/backend/types.py index 8a793b4f1..470206a7d 100644 --- a/src/idom/backend/types.py +++ b/src/idom/backend/types.py @@ -1,8 +1,9 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass -from typing import Any, MutableMapping, TypeVar +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Generic, MutableMapping, TypeVar from typing_extensions import Protocol, runtime_checkable @@ -56,3 +57,14 @@ class Location: search: str = "" """A search or query string - a '?' followed by the parameters of the URL.""" + + +_State = TypeVar("_State") + + +@dataclass +class SessionState(Generic[_State]): + id: str + value: _State + expiry_date: datetime + fresh: bool = field(default_factory=lambda: True) diff --git a/src/idom/backend/utils.py b/src/idom/backend/utils.py index b891ec793..860902f39 100644 --- a/src/idom/backend/utils.py +++ b/src/idom/backend/utils.py @@ -5,15 +5,18 @@ import os import socket from contextlib import closing +from dataclasses import dataclass +from datetime import datetime, timezone from importlib import import_module from pathlib import Path -from typing import Any, Iterator +from typing import Any, Awaitable, Callable, Generic, Iterator, TypeVar import idom from idom.config import IDOM_WEB_MODULES_DIR +from idom.core.hooks import create_context, use_context from idom.types import RootComponentConstructor -from .types import BackendImplementation +from .types import BackendImplementation, SessionState logger = logging.getLogger(__name__) @@ -28,6 +31,50 @@ ) +SESSION_COOKIE_NAME = "IdomSessionId" + + +_State = TypeVar("_State") + + +class SessionManager(Generic[_State]): + def __init__( + self, + create_state: Callable[[], Awaitable[_State]], + read_state: Callable[[str], Awaitable[_State | None]], + update_state: Callable[[_State], Awaitable[None]], + ): + self.context = create_context(None) + self._create_state = create_state + self._read_state = read_state + self._update_state = update_state + + def use_context(self) -> _State: + return use_context(self.context) + + async def update_state(self, state: SessionState[_State]) -> None: + await self._update_state(state) + logger.info(f"Updated session {state.id}") + + async def get_state(self, id: str | None = None) -> SessionState[_State] | None: + if id is None: + state = await self._create_state() + logger.info(f"Created new session {state.id!r}") + else: + state = await self._read_state(id) + if state is None: + logger.info(f"Could not load session {id!r}") + state = await self._create_state() + logger.info(f"Created new session {state.id!r}") + elif state.expiry_date < datetime.now(timezone.utc): + logger.info(f"Session {id!r} expired at {state.expiry_date}") + state = await self._create_state() + logger.info(f"Created new session {state.id!r}") + else: + logger.info(f"Loaded existing session {id!r}") + return state + + def run( component: RootComponentConstructor, host: str = "127.0.0.1", pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy