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",