add more docs

Commit 621f928 · patx · 2025-10-20T00:40:45-04:00

Changeset
621f92821fb49c9fb55184da4120112797101fb1
Parents
6ba96b1bdafef67b7d81bb876dcf0d43a84410bc

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/docs/apidocs/conf.py b/docs/apidocs/conf.py
index c2bfb74..3114105 100644
--- a/docs/apidocs/conf.py
+++ b/docs/apidocs/conf.py
@@ -6,9 +6,25 @@ release = "0.20"
 
 extensions = [
     "sphinx.ext.autodoc",
+    "sphinx.ext.autosummary",
+    "sphinx.ext.doctest",
+    "sphinx.ext.intersphinx",
+    "sphinx.ext.napoleon",
+    "sphinx.ext.todo",
     "sphinx.ext.viewcode",
 ]
 
+autosummary_generate = True
+autodoc_typehints = "description"
+napoleon_google_docstring = True
+napoleon_numpy_docstring = False
+todo_include_todos = True
+
+intersphinx_mapping = {
+    "python": ("https://docs.python.org/3", {}),
+    "asgiref": ("https://asgiref.readthedocs.io/en/latest", {}),
+}
+
 templates_path = ["_templates"]
 exclude_patterns = []
 
@@ -19,6 +35,11 @@ html_theme_options = {
     "github_user": "patx",
     "github_repo": "micropie",
     "fixed_sidebar": True,
+    "extra_nav_links": {
+        "Project README": "https://github.com/patx/micropie",
+    },
 }
+
+pygments_style = "friendly"
 html_static_path = ["_static"]
 
diff --git a/docs/apidocs/explanation/architecture.rst b/docs/apidocs/explanation/architecture.rst
index a55c720..349b725 100644
--- a/docs/apidocs/explanation/architecture.rst
+++ b/docs/apidocs/explanation/architecture.rst
@@ -73,6 +73,27 @@ responses in a small loop that listens for disconnect events and
 cancels the generator accordingly.  Remember to include a
 ``Content‑Type: text/event-stream`` header when sending SSE.
 
+Lifespan hooks
+--------------
+
+ASGI defines a lifespan protocol for startup and shutdown events.  MicroPie
+exposes ``startup_handlers`` and ``shutdown_handlers`` lists on the
+:class:`~micropie.App` instance.  Handlers are executed sequentially and
+may be synchronous or asynchronous.  Use them to open database connections,
+prime caches or register background tasks.  Lifespan functions run before
+any request or WebSocket traffic is accepted, ensuring your dependencies
+are ready.
+
+Templating and JSON helpers
+---------------------------
+
+If :mod:`jinja2` is installed, MicroPie enables the
+:func:`~micropie.App.render_template` helper to render templates from a
+``templates`` directory, returning HTML responses with the correct
+``Content-Type``.  For JSON, the framework prefers :mod:`orjson` when
+available and gracefully falls back to :mod:`json`.  This keeps the core
+lean while letting you opt into performance boosts.
+
 Error handling
 --------------
 
@@ -92,4 +113,14 @@ protocols like Socket.IO, or implement your own session storage.  The
 framework imposes few constraints so that you remain in control of
 your stack.
 
-.. _ASGI: https://asgi.readthedocs.io/
\ No newline at end of file
+WebSocket pipeline
+------------------
+
+WebSocket connections follow a parallel flow to HTTP requests.  The
+``ws_`` method naming convention resolves handlers, middleware gates the
+connection before :meth:`~micropie.WebSocket.accept` is called, and the
+:class:`~micropie.WebSocket` helper manages receive/send coroutines.  Session
+data is shared with HTTP handlers so users can authenticate once and reuse
+the same session across protocols.
+
+.. _ASGI: https://asgi.readthedocs.io/
diff --git a/docs/apidocs/explanation/design_philosophy.rst b/docs/apidocs/explanation/design_philosophy.rst
new file mode 100644
index 0000000..03d1db3
--- /dev/null
+++ b/docs/apidocs/explanation/design_philosophy.rst
@@ -0,0 +1,80 @@
+Design philosophy
+=================
+
+Understanding MicroPie's guiding principles makes it easier to decide
+whether the framework fits your project and how to extend it without
+fighting the grain. This page complements the
+:doc:`architecture <architecture>` overview by focusing on the trade-offs
+behind key decisions.
+
+Keep the core tiny
+------------------
+
+MicroPie prefers explicit, readable Python over layers of abstraction.
+The entire framework fits in a single module so you can audit its
+behaviour quickly. Features graduate into the core only if they pull
+their weight for the majority of applications. Everything else—ORMs,
+background workers, dependency injection—remains a userland concern.
+
+Route by convention, customise with middleware
+----------------------------------------------
+
+Automatic route discovery lowers the barrier to entry: write a method,
+get an endpoint. The flip side is that large applications sometimes need
+more deliberate routing. Instead of complicating the core dispatcher,
+MicroPie encourages you to plug in :class:`~micropie.HttpMiddleware`
+that rewrites the request target or delegates to sub-apps. This keeps the
+framework flexible without sacrificing the quick-start experience.
+
+Treat async as the default
+--------------------------
+
+ASGI enables concurrency, so MicroPie leans into ``async``/``await`` at
+every layer. Synchronous handlers are supported for convenience, but the
+internal plumbing always assumes asynchronous execution. Middleware and
+session backends follow the same rule. When you need to integrate a
+blocking library, run it in a thread pool explicitly so the framework
+stays responsive.
+
+Design for graceful degradation
+-------------------------------
+
+Optional dependencies such as ``jinja2``, ``multipart`` and ``orjson``
+are detected at runtime. If they are missing, MicroPie falls back to
+standard-library implementations. This approach keeps installation
+friction low for small services while allowing power users to opt in to
+richer features.
+
+Favour composition over special cases
+-------------------------------------
+
+Rather than adding bespoke APIs for every scenario, MicroPie exposes a
+few flexible hooks:
+
+* Context variables keep the current request accessible without global
+  state.
+* Middleware runs before and after handlers for both HTTP and WebSocket
+  flows.
+* Session backends are swappable classes with a small, well-documented
+  interface.
+
+By composing these building blocks you can implement authentication,
+logging, rate limiting and other behaviours without touching the core.
+
+Balance ergonomics with transparency
+------------------------------------
+
+MicroPie does not hide the ASGI protocol. Request and WebSocket objects
+mirror the underlying scope so you can drop to the metal when required.
+At the same time, helper methods normalise headers, cookies and body
+parsing. The goal is to keep magic to a minimum while smoothing common
+operations.
+
+Where to go next
+----------------
+
+* Revisit the :doc:`tutorials <../tutorial/index>` with the design goals
+  in mind to see how they shape the developer experience.
+* Browse ``tests.py`` to understand the expected behaviour of each hook.
+* If you plan to extend MicroPie, read the
+  :doc:`reference/index` to learn the public APIs you can rely on.
diff --git a/docs/apidocs/explanation/index.rst b/docs/apidocs/explanation/index.rst
index 0d3ac07..d6f97aa 100644
--- a/docs/apidocs/explanation/index.rst
+++ b/docs/apidocs/explanation/index.rst
@@ -10,4 +10,5 @@ precise definitions consult the reference.
    :maxdepth: 1
    :caption: Explanations
 
-   architecture
\ No newline at end of file
+   architecture
+   design_philosophy
diff --git a/docs/apidocs/glossary.rst b/docs/apidocs/glossary.rst
index c02a776..677ed15 100644
--- a/docs/apidocs/glossary.rst
+++ b/docs/apidocs/glossary.rst
@@ -41,4 +41,21 @@ This glossary defines terms used throughout the MicroPie documentation.
      local to the current asynchronous context.  MicroPie stores the
      current request in a context variable so it is accessible via
      :meth:`~micropie.App.request` without passing it through your
-     function calls.
\ No newline at end of file
+     function calls.
+
+   lifespan
+     The ASGI lifecycle protocol that allows applications to run
+     startup and shutdown hooks.  MicroPie exposes
+     ``startup_handlers`` and ``shutdown_handlers`` lists so you can
+     register functions that prepare resources before serving traffic.
+
+   session backend
+     The implementation responsible for persisting session data.
+     MicroPie ships with an in-memory backend but allows you to
+     implement :class:`~micropie.SessionBackend` to store sessions in
+     Redis, databases or other stores.
+
+   ASGI server
+     A web server capable of speaking the ASGI protocol and invoking
+     your application callable.  Examples include ``uvicorn``,
+     ``hypercorn`` and ``daphne``.
diff --git a/docs/apidocs/howto/index.rst b/docs/apidocs/howto/index.rst
index 6d3e1a2..df073d4 100644
--- a/docs/apidocs/howto/index.rst
+++ b/docs/apidocs/howto/index.rst
@@ -16,4 +16,5 @@ introduction.
    templates
    streaming
    static_files
-   socketio
\ No newline at end of file
+   socketio
+   testing
diff --git a/docs/apidocs/howto/testing.rst b/docs/apidocs/howto/testing.rst
new file mode 100644
index 0000000..4c95191
--- /dev/null
+++ b/docs/apidocs/howto/testing.rst
@@ -0,0 +1,126 @@
+Testing MicroPie applications
+=============================
+
+This guide shows practical approaches for testing MicroPie applications.
+MicroPie does not ship with a bespoke test client, but because it is a
+regular ASGI application you can exercise it using familiar Python
+libraries such as :mod:`unittest`, :mod:`pytest` and
+:mod:`httpx`'s ASGI tools.
+
+Choosing a test framework
+-------------------------
+
+MicroPie itself uses :class:`unittest.IsolatedAsyncioTestCase` in
+``tests.py`` to run asynchronous tests. ``pytest`` with the
+``pytest-asyncio`` plugin offers a similar developer experience. Pick the
+library that best matches your project's conventions—the examples below
+work with either.
+
+Unit testing handlers directly
+------------------------------
+
+Because handlers are regular functions, you can instantiate your
+:class:`~micropie.App` subclass and call methods directly. Use the
+:func:`micropie.current_request` context variable to set up any request
+state that your handler expects.
+
+.. code-block:: python
+
+   from contextvars import Token
+
+   from micropie import App, Request, current_request
+
+   class MyApp(App):
+       async def greet(self, name="World"):
+           return f"Hello {name}!"
+
+   async def test_greet_uses_default():
+       app = MyApp()
+       scope = {"type": "http", "method": "GET", "path": "/"}
+       request = Request(scope)
+       token: Token = current_request.set(request)
+       try:
+           response = await app.greet()
+       finally:
+           current_request.reset(token)
+       assert response == "Hello World!"
+
+Testing through the ASGI interface
+----------------------------------
+
+For higher confidence, drive the full ASGI stack. ``httpx`` provides an
+``ASGITransport`` class that can mount a MicroPie app. Install ``httpx``
+with ``pip install httpx``. The example below uses ``pytest`` style
+asserts, but the structure works in ``unittest`` with
+``self.assertEqual``.
+
+.. code-block:: python
+
+   import pytest
+   import httpx
+
+   from micropie import App
+
+   class MyApp(App):
+       async def index(self):
+           return {"status": "ok"}
+
+   @pytest.mark.asyncio
+   async def test_index_returns_json():
+       app = MyApp()
+       async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
+           response = await client.get("http://test/" )
+       assert response.status_code == 200
+       assert response.json() == {"status": "ok"}
+
+Simulating sessions and middleware
+----------------------------------
+
+To assert session behaviour, populate ``scope["headers"]`` with a
+``cookie`` header and inspect the response headers for the updated
+``Set-Cookie`` value. Middleware can be tested by attaching it to your
+app instance before issuing requests.
+
+.. code-block:: python
+
+   from micropie import App, HttpMiddleware
+
+   class AddHeader(HttpMiddleware):
+       async def after_request(self, request, response):
+           response.setdefault("headers", []).append((b"x-test", b"1"))
+           return response
+
+   class MyApp(App):
+       async def index(self):
+           return "hi"
+
+   async def test_middleware_header():
+       app = MyApp()
+       app.middleware.append(AddHeader())
+       transport = httpx.ASGITransport(app=app)
+       async with httpx.AsyncClient(transport=transport) as client:
+           response = await client.get("http://test/")
+       assert response.headers["x-test"] == "1"
+
+Handling lifespan events
+------------------------
+
+If your application registers startup or shutdown handlers, wrap your
+ASGI client in a lifespan manager. ``httpx`` exposes
+:class:`httpx.ASGITransport` with a ``lifespan="auto"`` mode that will
+run lifespan events before the first request and after the client exits.
+
+.. code-block:: python
+
+   async with httpx.AsyncClient(
+       transport=httpx.ASGITransport(app=app, lifespan="auto")
+   ) as client:
+       ...
+
+Further reading
+---------------
+
+* Browse ``tests.py`` in the MicroPie source tree for additional
+  patterns, including WebSocket testing helpers.
+* The `httpx documentation <https://www.python-httpx.org/advanced/#calling-into-python-web-apps>`_
+  has more on driving ASGI apps from tests.
diff --git a/docs/apidocs/index.rst b/docs/apidocs/index.rst
index 3ab55be..9d3d3c0 100644
--- a/docs/apidocs/index.rst
+++ b/docs/apidocs/index.rst
@@ -11,6 +11,39 @@ session back‑ends, middleware hooks, WebSocket support and optional
 template rendering.  Its focus on minimalism makes it a good choice for
 lightweight services, APIs and educational projects.
 
+Core features
+-------------
+
+* **Convention over configuration.** Public methods on your
+  :class:`~micropie.App` subclass automatically become routes so you can
+  ship a prototype with only a handful of lines of code.
+* **Async‑first request handling.** MicroPie speaks ASGI fluently and
+  embraces ``async``/``await`` for HTTP and WebSocket handlers while
+  keeping synchronous handlers ergonomic.
+* **Built‑in sessions and middleware.** A pluggable session backend and
+  request/response middleware hooks make it easy to add authentication,
+  analytics and other cross‑cutting concerns.
+* **Optional batteries included.** Extras for templating, fast JSON and
+  multipart parsing allow you to scale capabilities without bloating the
+  core package.
+
+Learning path
+-------------
+
+New to MicroPie? Follow this recommended progression:
+
+1. :doc:`tutorial/quickstart` – install the framework and serve your
+   first response.
+2. :doc:`tutorial/routing` – understand how method names map to URL
+   paths and how handler arguments are populated.
+3. :doc:`tutorial/websockets` – build a live, bidirectional endpoint.
+4. :doc:`howto/index` – explore recipes for common tasks like sessions,
+   templating and streaming.
+5. :doc:`reference/index` – deep dive into class and function
+   definitions when you need the authoritative contract.
+6. :doc:`explanation/index` – read about the philosophy behind the
+   design to better understand trade‑offs and extension points.
+
 This documentation is organized according to the
 Diátaxis documentation framework. That framework separates documentation into
 four distinct types:
@@ -26,12 +59,13 @@ four distinct types:
 * **Explanation** – discussions that explore the rationale behind
   design choices and deeper concepts in MicroPie.
 
-In addition to these four types, a small glossary collects
-terminology used throughout the framework.  If you are new to
-MicroPie, start with the tutorial.  If you want to accomplish a
-specific task, head to the how‑to guides.  If you need to look up a
-particular method or class, consult the reference manual.  For
-background and design considerations, see the explanation section.
+In addition to these four types, a small glossary collects terminology
+used throughout the framework.  Quick links to other helpful resources:
+
+* :doc:`glossary` – definitions of common MicroPie and ASGI terms.
+* :doc:`whats_new` – highlights from recent releases and upgrade tips.
+* `Project README <https://github.com/patx/micropie#readme>`_ – the
+  high-level project overview from the source repository.
 
 .. _Diátaxis documentation framework: https://diataxis.fr/
 
@@ -46,6 +80,7 @@ Contents
    howto/index
    reference/index
    explanation/index
+   whats_new
    glossary
 
 Indices and tables
diff --git a/docs/apidocs/whats_new.rst b/docs/apidocs/whats_new.rst
new file mode 100644
index 0000000..7001783
--- /dev/null
+++ b/docs/apidocs/whats_new.rst
@@ -0,0 +1,59 @@
+.. _release-notes:
+
+What's new in MicroPie
+======================
+
+This page summarises noteworthy changes in recent MicroPie releases. It
+is not an exhaustive changelog, but it highlights the features and bug
+fixes most likely to affect application developers. For the full list of
+releases, consult the `GitHub releases page <https://github.com/patx/micropie/releases>`_.
+
+Version highlights
+------------------
+
+* **0.23** – Ensures background multipart parsing stops immediately when a
+  request is terminated by middleware, preventing unnecessary resource
+  usage.
+* **0.22** – Fixes body parsing in mounted sub-applications by sharing
+  ``body_params`` and ``body_parsed`` state with the parent request.
+* **0.21** – Allows the implicit ``index`` route handler to receive path
+  parameters, aligning it with other handlers.
+* **0.20** – Introduces concurrent multipart parsing with bounded queues
+  so that large uploads do not block other requests.
+* **0.19** – Improves debugging via richer tracebacks and adds the
+  ``_sub_app`` attribute for mounting other ASGI applications.
+* **0.18** – Cancels asynchronous generator handlers when the client
+  disconnects to avoid leaking resources during streaming responses.
+* **0.17** – Updates the lifespan hook API to match middleware APIs,
+  enabling ``app.startup_handlers.append(handler)`` style usage.
+* **0.16** – Adds first-class lifespan event support with
+  ``on_startup`` and ``on_shutdown`` helpers.
+* **0.15** – Hardens optional dependency imports, improving behaviour
+  when extras such as ``orjson`` or ``jinja2`` are unavailable.
+* **0.14** – Renames the package import to ``micropie`` (breaking change)
+  to align module naming with Python conventions.
+* **0.13** – Introduces built-in WebSocket support, opening the door to
+  real-time applications without additional middleware.
+
+Upgrade tips
+------------
+
+* **Check optional extras.** When upgrading, confirm that any optional
+  dependencies you rely on (``jinja2``, ``multipart``, ``orjson``) are
+  still installed, especially if you pin minimal environments.
+* **Review lifespan handlers.** Releases 0.16 and 0.17 reshape the
+  startup/shutdown API. Adjust custom startup hooks to use the new
+  ``app.startup_handlers`` and ``app.shutdown_handlers`` lists.
+* **Audit long-lived streams.** If you emit server-sent events or
+  streaming responses, ensure your handlers handle cancellation so the
+  0.18 change does not mask cleanup bugs.
+* **Evaluate mounted applications.** If you mount other ASGI apps using
+  middleware, upgrade to at least 0.19 to benefit from the ``_sub_app``
+  attribute and the body parsing fixes introduced in 0.22.
+
+Looking for more?
+-----------------
+
+The :mod:`micropie` source distribution ships a ``docs/release_notes.md``
+file with the raw changelog. You can also browse historical discussions
+and pull requests on the project's `GitHub repository <https://github.com/patx/micropie>`_.
diff --git a/examples/middleware/csrf.py b/examples/middleware/csrf.py
index 38eca86..e76763f 100644
--- a/examples/middleware/csrf.py
+++ b/examples/middleware/csrf.py
@@ -1,51 +1,150 @@
 from typing import Optional, Dict, List, Tuple, Any
-from html import escape
+from urllib.parse import urlparse
 import uuid
-from itsdangerous import URLSafeTimedSerializer, BadSignature
+from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
 from micropie import App, HttpMiddleware, Request
 
 
 class CSRFMiddleware(HttpMiddleware):
-    """Middleware for CSRF protection using itsdangerous-signed tokens."""
-    def __init__(self, app: App, secret_key: str, max_age: int = 8 * 3600):
-        self.app = app  # Store the App instance
+    """
+    MicroPie-ready CSRF middleware using itsdangerous + session binding.
+
+    - Verifies on POST/PUT/PATCH/DELETE
+    - Accepts token from body (form or JSON) or 'X-CSRF-Token' header
+    - For multipart/form-data, strongly prefer header (parser may still be streaming)
+    - Token payload includes the session_id (when present) to bind token to that session
+    - Emits 'X-CSRF-Token' **only** when we create/rotate one during this request
+    - Supports exempt_paths (e.g. webhook endpoints like /sms_order)
+    """
+
+    def __init__(
+        self,
+        app: App,
+        secret_key: str,
+        *,
+        max_age: int = 8 * 3600,
+        trusted_origins: Optional[List[str]] = None,
+        body_field: str = "csrf_token",
+        header_name: str = "x-csrf-token",
+        require_header_for_multipart: bool = True,
+        exempt_paths: Optional[List[str]] = None,
+    ):
+        self.app = app
         self.serializer = URLSafeTimedSerializer(secret_key, salt="csrf-token")
         self.max_age = max_age
+        self.trusted = set(trusted_origins or [])  # e.g. ["https://gardenfresh.vegy.app"]
+        self.body_field = body_field
+        self.header_name = header_name.lower()
+        self.require_header_for_multipart = require_header_for_multipart
+        self.exempt_paths = set(exempt_paths or [])
 
-    async def before_request(self, request: Request) -> Optional[Dict]:
-        """Verify CSRF token for POST/PUT/PATCH requests and generate a new token if needed."""
-        # Extract session ID from cookies or generate a new one
-        session_id = request.headers.get("cookie", "").split("session_id=")[-1].split(";")[0] if "session_id=" in request.headers.get("cookie", "") else str(uuid.uuid4())
-
-        if request.method in ("POST", "PUT", "PATCH"):
-            print(f"Request body_params: {request.body_params}")  # Debugging
-            submitted_token = request.body_params.get("csrf_token", [""])[0]
-            print(f"Submitted CSRF token: {submitted_token}")  # Debugging
-            if not submitted_token:
-                return {"status_code": 403, "body": "Missing CSRF token"}
+    # ---------- helpers ----------
+
+    def _is_mutating(self, method: str) -> bool:
+        return method in ("POST", "PUT", "PATCH", "DELETE")
+
+    def _is_multipart(self, ct: str) -> bool:
+        return "multipart/form-data" in (ct or "")
+
+    def _origin_ok(self, headers: Dict[str, str]) -> bool:
+        if not self.trusted:
+            return True
+        origin = headers.get("origin")
+        referer = headers.get("referer")
+        for hdr in (origin, referer):
+            if not hdr:
+                continue
             try:
-                # Verify the submitted token's signature
-                self.serializer.loads(submitted_token, max_age=self.max_age)
-            except BadSignature:
-                return {"status_code": 403, "body": "Invalid or expired CSRF token"}
+                p = urlparse(hdr)
+                base = f"{p.scheme}://{p.netloc}"
+                if base in self.trusted:
+                    return True
+            except Exception:
+                pass
+        return False
+
+    def _get_session_id(self, request: Request) -> Optional[str]:
+        cookie = request.headers.get("cookie", "")
+        if "session_id=" in cookie:
+            return cookie.split("session_id=", 1)[1].split(";", 1)[0].strip() or None
+        return None
+
+    def _issue_token(self, session_id: Optional[str]) -> str:
+        payload = {"nonce": str(uuid.uuid4())}
+        if session_id:
+            payload["sid"] = session_id
+        return self.serializer.dumps(payload)
+
+    def _extract_submitted_token(self, request: Request) -> Optional[str]:
+        ct = request.headers.get("content-type", "")
+        if self._is_multipart(ct) and self.require_header_for_multipart:
+            token = request.headers.get(self.header_name)
+            if token:
+                return token
+
+        lst = request.body_params.get(self.body_field)
+        if lst and isinstance(lst, list) and lst:
+            return lst[0]
+
+        j = getattr(request, "get_json", None)
+        if isinstance(j, dict):
+            tok = j.get(self.body_field)
+            if isinstance(tok, str):
+                return tok
+
+        return request.headers.get(self.header_name)
+
+    # ---------- middleware hooks ----------
+
+    async def before_request(self, request: Request) -> Optional[Dict]:
+        path = request.scope.get("path", "")
+
+        # Exempt specific paths (e.g. /sms_order webhook)
+        if path in self.exempt_paths:
+            return None
 
-        # Generate a new CSRF token if one doesn't exist in the session
         if "csrf_token" not in request.session:
-            csrf_token = str(uuid.uuid4())
-            signed_token = self.serializer.dumps(csrf_token)
-            request.session["csrf_token"] = signed_token
-            # Save the session
-            print(f"Saving session with CSRF token: {signed_token}")  # Debugging
-            await self.app.session_backend.save(session_id, request.session, self.max_age)
+            sid = self._get_session_id(request)
+            request.session["csrf_token"] = self._issue_token(sid)
+            setattr(request, "_csrf_emit", request.session["csrf_token"])
+
+        if not self._is_mutating(request.method):
+            return None
+
+        if not self._origin_ok(request.headers):
+            return {"status_code": 403, "body": "Forbidden: invalid origin/referer"}
+
+        submitted = self._extract_submitted_token(request)
+        if not submitted:
+            return {"status_code": 403, "body": "Missing CSRF token"}
+
+        try:
+            data = self.serializer.loads(submitted, max_age=self.max_age)
+        except (BadSignature, SignatureExpired):
+            return {"status_code": 403, "body": "Invalid or expired CSRF token"}
+
+        sid = self._get_session_id(request)
+        token_sid = data.get("sid")
+        if token_sid is not None and (sid != token_sid):
+            return {"status_code": 403, "body": "Invalid CSRF token for this session"}
+
+        sid_now = self._get_session_id(request)
+        new_token = self._issue_token(sid_now)
+        request.session["csrf_token"] = new_token
+        setattr(request, "_csrf_emit", new_token)
 
         return None
 
     async def after_request(
-        self, request: Request, status_code: int, response_body: Any, extra_headers: List[Tuple[str, str]]
+        self,
+        request: Request,
+        status_code: int,
+        response_body: Any,
+        extra_headers: List[Tuple[str, str]],
     ) -> Optional[Dict]:
-        """Include CSRF token in response headers for client-side use."""
-        if request.session.get("csrf_token"):
-            extra_headers.append(("X-CSRF-Token", request.session["csrf_token"]))
+        to_emit = getattr(request, "_csrf_emit", None)
+        if to_emit:
+            extra_headers.append(("X-CSRF-Token", to_emit))
         return None
 
 
@@ -53,7 +152,6 @@ class Root(App):
 
     async def index(self):
         csrf_token = self.request.session.get("csrf_token", "")
-        print(f"Rendering form with CSRF token: {csrf_token}")
         return f"""<form method="POST" action="/submit">
             <input type="hidden" name="csrf_token" value="{csrf_token}">
             <input type="text" name="name">
@@ -67,4 +165,11 @@ class Root(App):
 
 
 app = Root()
-app.middlewares.append(CSRFMiddleware(app=app, secret_key="my-secret-key"))
+app.middlewares.append(
+    CSRFMiddleware(
+        app=app,
+        secret_key="my-secret-key",
+        exempt_paths=["/sms_order"],
+    )
+)
+
diff --git a/examples/pastebin/app.py b/examples/pastebin/app.py
index e0946de..2127e41 100644
--- a/examples/pastebin/app.py
+++ b/examples/pastebin/app.py
@@ -9,9 +9,9 @@ db = AsyncPickleDB('pastes.json')
 
 class Root(App):
 
-    async def index(self):
+    async def index(self, paste_content=None):
         if self.request.method == "POST":
-            paste_content = self.request.body_params.get('paste_content', [''])[0]
+            #paste_content = self.request.body_params.get('paste_content', [''])[0]
             pid = str(uuid4())
             await db.aset(pid, escape(paste_content))
             await db.asave()
diff --git a/examples/pastebin/pastes.json b/examples/pastebin/pastes.json
new file mode 100644
index 0000000..e77b949
--- /dev/null
+++ b/examples/pastebin/pastes.json
@@ -0,0 +1 @@
+{"11d967c7-5a5d-4441-8fb8-035c5c6e6df0":"{% extends &#34;base.html&#34; %}\r\n\r\n{% block title %}{{ client_name }} - Home{% endblock %}\r\n\r\n{% block content %}\r\n&lt;div class=&#34;jumbotron glass&#34;&gt;\r\n    &lt;h1 class=&#34;display-4&#34;&gt;\r\n        &lt;img src=&#34;https://raw.githubusercontent.com/patx/vegy-static/refs/heads/main/gardenfresh/gardenfresh_logo_clear.png&#34;\r\n             style=&#34;max-width: 90%;&#34; alt=&#34;Garden Fresh Logo&#34;&gt;\r\n    &lt;/h1&gt;\r\n    {% if request.session.logged_in %}\r\n    &lt;p class=&#34;lead&#34;&gt;Welcome {{ request.session.email }}!&lt;/p&gt;\r\n    {% else %}\r\n    &lt;p class=&#34;lead&#34;&gt;Order fresh produce easily!&lt;/p&gt;\r\n    {% endif %}\r\n    {% if message == &#34;registered&#34; %}\r\n    &lt;div class=&#34;alert alert-warning&#34;&gt;\r\n        Your account has been successfully registered! A team member will reach out shortly after we approve your account. \r\n        You will not be able to log in until we approve your new account. \r\n        &lt;strong&gt;\r\n            You can install our app now if you are on your mobile device. \r\n            &lt;a href=&#34;https://github.com/patx/vegy-static/raw/refs/heads/main/gardenfresh/install.MP4?raw=True&#34; target=&#34;_blank&#34;&gt;\r\n                Watch the installation video here\r\n            &lt;/a&gt;!\r\n        &lt;/strong&gt; \r\n        If your account has already been approved you may login below.\r\n    &lt;/div&gt;\r\n    {% endif %}\r\n    {% if request.session.logged_in and request.session.admin %}\r\n    &lt;a class=&#34;btn btn-primary btn-lg&#34; href=&#34;/admin&#34; role=&#34;button&#34;&gt;Orders&lt;/a&gt;\r\n    &lt;a class=&#34;btn btn-primary btn-lg&#34; href=&#34;/items&#34; role=&#34;button&#34;&gt;Items&lt;/a&gt;\r\n    &lt;a class=&#34;btn btn-primary btn-lg&#34; href=&#34;/invites&#34; role=&#34;button&#34;&gt;Accounts&lt;/a&gt;\r\n    &lt;br&gt;&lt;br&gt;&lt;br&gt;\r\n    &lt;a href=&#34;/logout&#34; class=&#34;btn btn-sm btn-outline-warning&#34;&gt;Logout&lt;/a&gt;\r\n    &lt;button type=&#34;button&#34; class=&#34;btn btn-sm btn-outline-warning&#34; data-bs-toggle=&#34;modal&#34; data-bs-target=&#34;#changePasswordModal&#34;&gt;\r\n        Change Password\r\n    &lt;/button&gt;\r\n    {% elif request.session.logged_in %}\r\n    &lt;a class=&#34;btn btn-primary btn-lg&#34; href=&#34;/new_order&#34; role=&#34;button&#34;&gt;Place a New Order&lt;/a&gt;&lt;br&gt;&lt;br&gt;\r\n    &lt;a class=&#34;btn btn-primary btn-lg&#34; href=&#34;/orders&#34; role=&#34;button&#34;&gt;View Your Orders&lt;/a&gt;\r\n    &lt;br&gt;&lt;br&gt;&lt;br&gt;\r\n    &lt;a href=&#34;/logout&#34; class=&#34;btn btn-sm btn-outline-warning&#34;&gt;Logout&lt;/a&gt;\r\n    &lt;button type=&#34;button&#34; class=&#34;btn btn-sm btn-outline-warning&#34; data-bs-toggle=&#34;modal&#34; data-bs-target=&#34;#changePasswordModal&#34;&gt;\r\n        Change Password\r\n    &lt;/button&gt;\r\n    {% else %}\r\n    &lt;a class=&#34;btn btn-primary btn-lg&#34; href=&#34;/login&#34; role=&#34;button&#34;&gt;Login&lt;/a&gt;\r\n    {% endif %}\r\n\r\n    &lt;!-- Change Password Modal --&gt;\r\n    {% if request.session.logged_in %}\r\n    &lt;!-- keep your modal + script code here --&gt;\r\n    {% endif %}\r\n&lt;/div&gt;\r\n\r\n&lt;style&gt;\r\n/* === Full-page background === */\r\nhtml, body {\r\n    height: 100%;\r\n    margin: 0;\r\n    background: url(&#34;https://raw.githubusercontent.com/patx/vegy-static/main/screenshots/bg.jpg&#34;)\r\n                no-repeat center center fixed;\r\n    background-size: cover;\r\n}\r\n\r\n/* Buttons &amp; text spacing */\r\na, button { margin-top: 10px; }\r\n\r\n/* Logo heading */\r\nh1.display-4 {\r\n    font-family: &#34;Poppins&#34;, sans-serif;\r\n    font-weight: 800;\r\n}\r\n\r\n/* Center hero */\r\n.container {\r\n    max-width: 1200px;\r\n    flex: 1;\r\n    display: flex;\r\n    justify-content: center;\r\n    align-items: center;\r\n}\r\n\r\n/* Frosted glass effect for content */\r\n.jumbotron.glass {\r\n    text-align: center;\r\n    padding: 2rem;\r\n    width: 100%;\r\n    max-width: 600px;\r\n    margin: 2rem auto;\r\n    background: rgba(255, 255, 255, 0.5); /* semi-transparent white */\r\n    border-radius: 16px;\r\n    box-shadow: 0 8px 24px rgba(0,0,0,0.2);\r\n    backdrop-filter: blur(12px);           /* frosted blur */\r\n    -webkit-backdrop-filter: blur(12px);   /* Safari support */\r\n    margin-top: 200px;\r\n}\r\nh1 { margin-top: 0; }\r\n&lt;/style&gt;\r\n{% endblock %}\r\n"}
\ No newline at end of file