diff --git a/docs/adr/038-storage-binary-copy-and-request-scoped-vite-inertia.md b/docs/adr/038-storage-binary-copy-and-request-scoped-vite-inertia.md new file mode 100644 index 00000000..19300bdb --- /dev/null +++ b/docs/adr/038-storage-binary-copy-and-request-scoped-vite-inertia.md @@ -0,0 +1,60 @@ +# 038. Storage binary reads and relative copy; request-scoped Vite and Inertia state + +Date: 2026-10-25 + +## Problem + +Storage (`storage/drivers/local.py`, `s3.py`): + +- `LocalDriver.copy` passed its arguments straight to `shutil.copyfile`, so `Storage.copy("a.txt", "b.txt")` resolved against the process working directory instead of the disk root and raised `FileNotFoundError`. +- `LocalDriver.get` opened files in text mode and `S3Driver.get` decoded the body as UTF-8, so reading a binary file such as a `.png` raised `UnicodeDecodeError`. No driver offered a bytes-returning read. +- `S3Driver.append` built the combined content but uploaded only the new `content`, so every append replaced the object. + +Vite (`vite/vite.py`): + +- `use_script_tag_attributes`, `use_style_tag_attributes` and `use_preload_tag_attributes` wrapped a dict as `attributes = lambda *_: attributes`. The lambda closes over the rebound name, so it returned itself and `dict.update` raised `TypeError`. Passing a dict, as documented, never worked. +- The CSP nonce and preloaded-asset map were instance attributes on the single `vite` container binding. A nonce set while handling one request was used by every concurrent request. + +Inertia (`inertia/inertia.py`): + +- `Inertia.set_root_view` and `Inertia.version` always wrote to the process-wide `ResponseFactory`. Called inside a request, they changed the root view and version for every concurrent and later request. ADR 007 already gives each request an `InertiaRequestState`; these two setters ignored it. + +## Alternatives + +Binary reads: + +1. Change `get` to always return `bytes`. Breaks every caller and test that expects text. +2. Add only a `get_bytes` method. Keeps `get` crashing on binary files. +3. `get` reads bytes and returns `str` when the content is valid UTF-8, otherwise the raw `bytes`; plus a `get_bytes` that always returns `bytes`. + +Per-request Vite state: + +1. Create a `Vite` instance per request. Every template global and facade would need a request-aware lookup. +2. Keep the nonce and preloaded assets in bare `ContextVar`s. A value set in a child task never reaches the parent. A dict created lazily in an outer context (boot, warm-up, CLI) is inherited by every later request task, which brings the leak back. A `BaseHTTPMiddleware` that reads after `call_next` sees nothing, because the endpoint runs in a child task. A nonce set during lifespan, which uvicorn runs in its own task, never reaches requests. +3. Give each task its own state, the way `Cache.memo` does (ADR 029). This removes the leak, but a middleware reading after `call_next` still sees nothing, and a nonce set at startup still does not act as a default. +4. Open an explicit request scope in a small ASGI middleware. The scope holds the state object for that request and shares it with every child task of the request. Outside a scope, the instance-level values act as process-wide defaults. + +## Decision + +- `LocalDriver.copy` resolves both paths through `get_path`, like `move`. Absolute paths still work because `os.path.join` keeps them. +- Binary reads take option 3 on both the local and S3 drivers. `get_bytes` returns `None` for a missing file, the same as `get`. `decode_text` in `storage/helper.py` is shared. `StorageManager` and the `Storage` facade proxy `get_bytes`. Reading in binary mode means `get` no longer converts `\r\n` to `\n`. +- `get` only decodes UTF-8. A text file in another encoding (for example latin-1 `caf\xe9`) is not valid UTF-8, so `get` returns it as `bytes`. Callers that need a fixed type use `get_bytes` and decode it themselves. +- `S3Driver.append` uploads the existing content followed by the new content, and returns the new content like `LocalDriver.append`. +- Vite turns non-callable attributes into a resolver through `_as_resolver`, which binds the value instead of the name. Dicts and `False` (to skip preloads) now work. +- Vite takes option 4. `Vite.request_scope()` is a context manager. It sets a fresh `ViteRequestState` (nonce and preloaded assets) in a per-instance `ContextVar` and resets it on exit. A nested scope reuses the outer state. Inside a scope, `use_csp_nonce` sets the request nonce and `csp_nonce` returns it, falling back to the process-wide default. `preloaded_assets` and `flush` act on the request dict. Outside a scope, they act on the instance-level default nonce and dict, the same as before this change. So a nonce set at startup or in lifespan is the default for every request. +- `ViteMiddleware` is a pure ASGI middleware. It wraps each `http` and `websocket` scope in `vite.request_scope()` and passes `lifespan` through unchanged. It takes `vite=` or resolves `vite` from the container on first use. `ViteProvider.boot` registers it when FastAPI is installed. Because it is pure ASGI, the endpoint and any inner `BaseHTTPMiddleware` share the same state object, so a Link-header middleware inside it can read `preloaded_assets()` after `call_next`. Middleware added with `app.add_middleware` after providers boot sits outside the provider's `ViteMiddleware`. If such a middleware reads preloaded assets, add `ViteMiddleware` again after it. The outer instance opens the scope, and the inner one reuses it. +- The manifest cache stays shared, because it is keyed by path and does not depend on the request. +- `ResponseFactory.set_root_view` and `set_version` write to the current `InertiaRequestState` when one exists, and to the global defaults otherwise, the same as `share` and `set_encrypt_history`. A version set during a request is resolved immediately and wins over the middleware's version for that request. + +## Validation + +- Local driver: relative `copy`, a UTF-8 `get`, a binary `get` and `get_bytes`, and a missing file through `get_bytes`. The S3 driver gets matching tests plus `append` with and without existing content, and a `Storage.fake` test covers the facade. +- Vite: dict attributes for script, style and preload tags, and `False` for preloads. `tests/vite/test_request_scope.py` covers the following: + - An outer render followed by two concurrent requests: each request sees only its own key. + - Three concurrent requests with a default nonce: two override it, and one keeps the default. + - A nonce set in a separate startup task reaches requests. + - An inner `BaseHTTPMiddleware` reads the preloaded assets after `call_next`. + - An outer `ViteMiddleware` wraps middleware added after the provider's. + - Lifespan passes through, nested scopes are reused, container resolution works, and the provider registers the middleware. +- Inertia: two concurrent requests through `InertiaMiddleware` each set a root view and version, then wait on a shared barrier. Each response returns its own values, and the global defaults are unchanged. Outside a request, the setters still configure the defaults. +- All new tests fail on the old code. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index c13f7f1b..7b657eb1 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -35,3 +35,4 @@ Read this index first, then open the records relevant to the change. Before impl | [029](029-cache-module.md) | 2026-10-21 | Cache module with remember, memo, locks and tags | New `fastapi_startkit.cache`: async `Cache.get/put/has/forget/flush`, `get` with a callable default, and `remember(key, ttl, factory)` that runs a sync or async factory only on a miss; `remember(key, ttl)` without a factory is a decorator keyed by `key` plus a hash of the call arguments. Stores implement an async contract with a `MISSING` sentinel (cached `None` is a hit): array, file (pickled per-key files), database (masoniteorm table) and redis (named connection from the existing `RedisManager`, `SET PX` TTL, flush by `SCAN` of prefixed keys, never `FLUSHDB`). TTL in seconds or `timedelta`, `None` forever, <=0 forgets. `CacheConfig` via `resolve_config`; `CacheProvider` binds `cache`; facade `remember` resolves lazily. `Cache.memo()` memoizes reads per asyncio task through a `ContextVar` owned by the task, so requests never share memo state. `Cache.lock(name, seconds, owner)` gives atomic, owner-checked, non-re-entrant locks with `get`, `block` (250 ms polling, `LockTimeoutError`), `release`, `force_release` and `async with`, per store: event-loop dict, `fcntl.flock` file, `lock:` row with update-if-expired, Redis `SET NX PX` plus a Lua owner-checked delete. `Cache.tags([...])` uses Laravel's namespaced keys: random tag ids at `:tag::key`, entries at `:sha1(ids):`, flush rewrites the ids; tags are sorted and deduplicated. Shipped in slices into `feature/caches`; the former memo, lock and tags ADRs (030, 031, 034) are folded in. | | [032](032-migrate-seed-option.md) | 2026-10-21 | db:migrate:fresh and db:migrate:refresh --seed run seeders in the same event loop | `--seed` called the unregistered `seed:run` through Cleo `call` (which also drops the first argument) inside a running event loop, where `db:seed` would call `asyncio.run` again. The seeder selection moves into async `DBSeedCommand.run_seeders`, which `db:seed`, `db:migrate:fresh` and `db:migrate:refresh` all await. `db:migrate:refresh` is now registered. | | [033](033-async-seeder-stub.md) | 2026-10-21 | Generated seeders have an async run | `create_seed.stub` generated a synchronous `run` with docstrings, but `Seeder` awaits `run()`, so every generated table seeder raised `TypeError`. The stub now generates `async def run(self) -> None` with no docstrings; `Seeder` is unchanged. | +| [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. | diff --git a/fastapi_startkit/src/fastapi_startkit/facades/Vite.pyi b/fastapi_startkit/src/fastapi_startkit/facades/Vite.pyi index 1e8380e6..c20f270b 100644 --- a/fastapi_startkit/src/fastapi_startkit/facades/Vite.pyi +++ b/fastapi_startkit/src/fastapi_startkit/facades/Vite.pyi @@ -1,3 +1,4 @@ +from contextlib import AbstractContextManager from typing import Callable, Optional class Vite: @@ -85,6 +86,8 @@ class Vite: """Reset per-request state (call after each response in long-running servers).""" ... + @staticmethod + def request_scope() -> AbstractContextManager[None]: ... @staticmethod def to_html() -> str: """Render pre-configured entry points as an HTML string.""" diff --git a/fastapi_startkit/src/fastapi_startkit/inertia/inertia.py b/fastapi_startkit/src/fastapi_startkit/inertia/inertia.py index a259dd1f..8cd1c768 100644 --- a/fastapi_startkit/src/fastapi_startkit/inertia/inertia.py +++ b/fastapi_startkit/src/fastapi_startkit/inertia/inertia.py @@ -42,7 +42,11 @@ def __init__(self): self.preserve_big_integers_enabled: bool = False def set_root_view(self, view: str): - self.root_view = view + state = current_state.get() + if state is not None: + state.root_view = view + else: + self.root_view = view def share(self, key: str | dict[str, Any], value: Any = None): state = current_state.get() @@ -58,7 +62,12 @@ def shared(self) -> dict[str, Any]: return {**self.shared_props, **request_props} def set_version(self, version): - self.version = version + state = current_state.get() + if state is not None: + state.resolved_version = self.resolve_version(version) + state.version_resolved = True + else: + self.version = version def get_version(self) -> str | None: state = current_state.get() diff --git a/fastapi_startkit/src/fastapi_startkit/storage/drivers/local.py b/fastapi_startkit/src/fastapi_startkit/storage/drivers/local.py index 199a0dc4..0ce42a45 100644 --- a/fastapi_startkit/src/fastapi_startkit/storage/drivers/local.py +++ b/fastapi_startkit/src/fastapi_startkit/storage/drivers/local.py @@ -5,7 +5,7 @@ from ..filestream import FileStream from ..file import File -from ..helper import get_extension +from ..helper import decode_text, get_extension class LocalDriver: @@ -55,11 +55,13 @@ def put_file(self, file_path, content, name=None): return os.path.join(file_path, file_name) def get(self, file_path): - try: - with open(self.get_path(file_path), "r") as f: - content = f.read() + content = self.get_bytes(file_path) + return None if content is None else decode_text(content) - return content + def get_bytes(self, file_path): + try: + with open(self.get_path(file_path), "rb") as f: + return f.read() except FileNotFoundError: return None @@ -75,7 +77,7 @@ def stream(self, file_path): return FileStream(content) def copy(self, from_file_path, to_file_path): - return copyfile(from_file_path, to_file_path) + return copyfile(self.get_path(from_file_path), self.get_path(to_file_path)) def move(self, from_file_path, to_file_path): return move(self.get_path(from_file_path), self.get_path(to_file_path)) diff --git a/fastapi_startkit/src/fastapi_startkit/storage/drivers/s3.py b/fastapi_startkit/src/fastapi_startkit/storage/drivers/s3.py index b9d17f5e..293c71db 100644 --- a/fastapi_startkit/src/fastapi_startkit/storage/drivers/s3.py +++ b/fastapi_startkit/src/fastapi_startkit/storage/drivers/s3.py @@ -2,7 +2,7 @@ import uuid from ..file import File -from ..helper import get_extension +from ..helper import decode_text, get_extension class S3Driver: @@ -67,12 +67,14 @@ def put_file(self, file_path, content, name=None): return os.path.join(file_path, file_name) def get(self, file_path): + content = self.get_bytes(file_path) + return None if content is None else decode_text(content) + + def get_bytes(self, file_path): try: - return ( - self.get_resource().Bucket(self.get_bucket()).Object(file_path).get().get("Body").read().decode("utf-8") - ) + return self.get_resource().Bucket(self.get_bucket()).Object(file_path).get().get("Body").read() except self.missing_file_exceptions(): - pass + return None def missing_file_exceptions(self): import botocore # pyright: ignore[reportMissingImports] # boto3 is an optional extra, imported lazily @@ -126,8 +128,8 @@ def prepend(self, file_path, content): def append(self, file_path, content): value = self.get(file_path) or "" - value += content - self.put(file_path, content) + self.put(file_path, value + content) + return content def delete(self, file_path): return self.get_resource().Object(self.get_bucket(), file_path).delete() diff --git a/fastapi_startkit/src/fastapi_startkit/storage/helper.py b/fastapi_startkit/src/fastapi_startkit/storage/helper.py index 08c62a49..5d4f59ec 100644 --- a/fastapi_startkit/src/fastapi_startkit/storage/helper.py +++ b/fastapi_startkit/src/fastapi_startkit/storage/helper.py @@ -31,3 +31,10 @@ def get_extension(filepath: str, without_dot=False) -> str: if without_dot: extension = extension[1:] return extension + + +def decode_text(content: bytes) -> str | bytes: + try: + return content.decode("utf-8") + except UnicodeDecodeError: + return content diff --git a/fastapi_startkit/src/fastapi_startkit/storage/storage.py b/fastapi_startkit/src/fastapi_startkit/storage/storage.py index 81e9b8c1..b458dc70 100644 --- a/fastapi_startkit/src/fastapi_startkit/storage/storage.py +++ b/fastapi_startkit/src/fastapi_startkit/storage/storage.py @@ -74,6 +74,9 @@ def put(self, *args, **kwargs): def get(self, *args, **kwargs): return self.disk().get(*args, **kwargs) + def get_bytes(self, *args, **kwargs): + return self.disk().get_bytes(*args, **kwargs) + def exists(self, *args, **kwargs): return self.disk().exists(*args, **kwargs) @@ -140,6 +143,10 @@ def put(cls, *args, **kwargs): def get(cls, *args, **kwargs): return cls.init().get(*args, **kwargs) + @classmethod + def get_bytes(cls, *args, **kwargs): + return cls.init().get_bytes(*args, **kwargs) + @classmethod def exists(cls, *args, **kwargs): return cls.init().exists(*args, **kwargs) diff --git a/fastapi_startkit/src/fastapi_startkit/vite/__init__.py b/fastapi_startkit/src/fastapi_startkit/vite/__init__.py index e4d4356f..036d24e2 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/__init__.py @@ -1,10 +1,12 @@ from .vite import Vite +from .middleware import ViteMiddleware from .config.vite import ViteConfig from .providers.provider import ViteProvider from .exceptions import ViteException, ViteManifestNotFoundException __all__ = [ "Vite", + "ViteMiddleware", "ViteConfig", "ViteProvider", "ViteException", diff --git a/fastapi_startkit/src/fastapi_startkit/vite/middleware.py b/fastapi_startkit/src/fastapi_startkit/vite/middleware.py new file mode 100644 index 00000000..cf05e7ca --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/vite/middleware.py @@ -0,0 +1,25 @@ +from typing import TYPE_CHECKING, Any + +from .vite import Vite + +if TYPE_CHECKING: + from starlette.types import ASGIApp, Receive, Scope, Send + + +class ViteMiddleware: + def __init__(self, app: "ASGIApp", vite: Vite | None = None) -> None: + self.app = app + self.vite = vite + + async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> Any: + if scope["type"] not in ("http", "websocket"): + return await self.app(scope, receive, send) + with self.resolve_vite().request_scope(): + return await self.app(scope, receive, send) + + def resolve_vite(self) -> Vite: + if self.vite is None: + from fastapi_startkit.application import app + + self.vite = app().make("vite") + return self.vite diff --git a/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py b/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py index c4630694..3eb704e4 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py @@ -1,9 +1,11 @@ import os +from importlib.util import find_spec from fastapi_startkit.support import Provider from ..config.vite import ViteConfig +from ..middleware import ViteMiddleware from ..vite import Vite @@ -53,6 +55,7 @@ def boot(self) -> None: config = ViteConfig(**config) self.mount_static_file_if_require(config) + self.register_request_scope(vite) self.register_jinja_directives(vite) source = os.path.abspath(str(os.path.join(str(os.path.dirname(__file__)), "../config/vite.py"))) @@ -86,6 +89,10 @@ def mount_static_file_if_require(self, config: ViteConfig): except ImportError: pass # fastapi optional dependency not installed + def register_request_scope(self, vite: Vite): + if find_spec("fastapi") is not None: + self.app.fastapi.add_middleware(ViteMiddleware, vite=vite) + def register_jinja_directives(self, vite: Vite): # Wire up Jinja2 globals when a template object is registered. # Wrap HTML-returning helpers in Markup so Jinja2 does not escape the tags. diff --git a/fastapi_startkit/src/fastapi_startkit/vite/vite.py b/fastapi_startkit/src/fastapi_startkit/vite/vite.py index 179f22bc..b1efb8c2 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/vite.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/vite.py @@ -4,12 +4,35 @@ import re import secrets from collections.abc import Callable +from contextvars import ContextVar, Token +from dataclasses import dataclass, field from typing import Any, Literal from .exceptions import ViteException, ViteManifestNotFoundException +@dataclass +class ViteRequestState: + nonce: str | None = None + preloaded_assets: dict[str, list[str]] = field(default_factory=dict) + + +class ViteRequestScope: + def __init__(self, request: ContextVar[ViteRequestState | None]) -> None: + self._request = request + self._token: Token[ViteRequestState | None] | None = None + + def __enter__(self) -> None: + if self._request.get() is None: + self._token = self._request.set(ViteRequestState()) + + def __exit__(self, *_: object) -> None: + if self._token is not None: + self._request.reset(self._token) + self._token = None + + class Vite: """Generates Vite asset tags for use in templates. @@ -44,7 +67,7 @@ def __init__( # Optional base URL prefix for all generated asset URLs. self._asset_url = asset_url.rstrip("/") - self._nonce: str | None = None + self._default_nonce: str | None = None self._integrity_key: str | bool = "integrity" self._entry_points: list[str] = [] self._asset_path_resolver: Callable[[str], str] | None = None @@ -55,6 +78,7 @@ def __init__( self._preload_tag_attributes_resolvers: list[Callable[..., Any]] = [] self._preloaded_assets: dict[str, list[str]] = {} + self._request: ContextVar[ViteRequestState | None] = ContextVar(f"vite_request_{id(self)}", default=None) # ------------------------------------------------------------------ # Public configuration API @@ -62,11 +86,19 @@ def __init__( def use_csp_nonce(self, nonce: str | None = None) -> str: """Generate (or set) a CSP nonce applied to all generated tags.""" - self._nonce = nonce or secrets.token_hex(20) - return self._nonce + value = nonce or secrets.token_hex(20) + request = self._request.get() + if request is not None: + request.nonce = value + else: + self._default_nonce = value + return value def csp_nonce(self) -> str | None: - return self._nonce + request = self._request.get() + if request is not None and request.nonce is not None: + return request.nonce + return self._default_nonce def use_integrity_key(self, key: str | bool) -> "Vite": """Set the manifest key used to read SRI integrity hashes.""" @@ -84,23 +116,23 @@ def create_asset_paths_using(self, resolver: Callable[[str], str] | None) -> "Vi return self def use_script_tag_attributes(self, attributes: dict[str, Any] | Callable[..., Any]) -> "Vite": - if not callable(attributes): - attributes = lambda *_: attributes # noqa: E731 - self._script_tag_attributes_resolvers.append(attributes) + self._script_tag_attributes_resolvers.append(self._as_resolver(attributes)) return self def use_style_tag_attributes(self, attributes: dict[str, Any] | Callable[..., Any]) -> "Vite": - if not callable(attributes): - attributes = lambda *_: attributes # noqa: E731 - self._style_tag_attributes_resolvers.append(attributes) + self._style_tag_attributes_resolvers.append(self._as_resolver(attributes)) return self def use_preload_tag_attributes(self, attributes: dict[str, Any] | bool | Callable[..., Any]) -> "Vite": - if not callable(attributes): - attributes = lambda *_: attributes # noqa: E731 - self._preload_tag_attributes_resolvers.append(attributes) + self._preload_tag_attributes_resolvers.append(self._as_resolver(attributes)) return self + @staticmethod + def _as_resolver(attributes: Any) -> Callable[..., Any]: + if callable(attributes): + return attributes + return lambda *_: attributes + # ------------------------------------------------------------------ # Core HTML generation # ------------------------------------------------------------------ @@ -215,7 +247,8 @@ def react_refresh(self) -> str: if not self.is_running_hot(): return "" - nonce_attr = f' nonce="{self._nonce}"' if self._nonce else "" + nonce = self.csp_nonce() + nonce_attr = f' nonce="{nonce}"' if nonce else "" hot_url = f"{self._read_hot_origin()}/@react-refresh" return ( @@ -247,11 +280,14 @@ def is_running_hot(self) -> bool: return os.path.isfile(self._hot_file) def preloaded_assets(self) -> dict[str, list[str]]: - return self._preloaded_assets + request = self._request.get() + return request.preloaded_assets if request is not None else self._preloaded_assets def flush(self) -> None: - """Reset per-request state (call this after each response in long-running servers).""" - self._preloaded_assets = {} + self.preloaded_assets().clear() + + def request_scope(self) -> "ViteRequestScope": + return ViteRequestScope(self._request) def to_html(self) -> str: """Render pre-configured entry points as HTML (used as Htmlable).""" @@ -343,7 +379,7 @@ def _make_preload_tag_for_chunk( if attributes is False: return "" - self._preloaded_assets[url] = self._parse_attributes({k: v for k, v in attributes.items() if k != "href"}) + self.preloaded_assets()[url] = self._parse_attributes({k: v for k, v in attributes.items() if k != "href"}) return f"" def _resolve_script_tag_attributes(self, src, url, chunk, manifest) -> dict[str, Any]: @@ -368,7 +404,7 @@ def _resolve_preload_tag_attributes(self, src, url, chunk, manifest) -> dict[str "rel": "preload", "as": "style", "href": url, - "nonce": self._nonce or False, + "nonce": self.csp_nonce() or False, "crossorigin": self._resolve_stylesheet_tag_attributes(src, url, chunk, manifest).get( "crossorigin", False ), @@ -377,7 +413,7 @@ def _resolve_preload_tag_attributes(self, src, url, chunk, manifest) -> dict[str attrs = { "rel": "modulepreload", "href": url, - "nonce": self._nonce or False, + "nonce": self.csp_nonce() or False, "crossorigin": self._resolve_script_tag_attributes(src, url, chunk, manifest).get("crossorigin", False), } @@ -397,7 +433,7 @@ def _make_script_tag_with_attributes(self, url: str, attributes: dict[str, Any]) { "type": "module", "src": url, - "nonce": self._nonce or False, + "nonce": self.csp_nonce() or False, **attributes, } ) @@ -408,7 +444,7 @@ def _make_stylesheet_tag_with_attributes(self, url: str, attributes: dict[str, A { "rel": "stylesheet", "href": url, - "nonce": self._nonce or False, + "nonce": self.csp_nonce() or False, **attributes, } ) diff --git a/fastapi_startkit/tests/inertia/test_inertia_context.py b/fastapi_startkit/tests/inertia/test_inertia_context.py index acbafa57..b0d35339 100644 --- a/fastapi_startkit/tests/inertia/test_inertia_context.py +++ b/fastapi_startkit/tests/inertia/test_inertia_context.py @@ -4,9 +4,12 @@ non-Inertia passthrough, InertiaResponse.__call__ without middleware. """ +import asyncio import unittest from unittest.mock import MagicMock +import httpx + from fastapi import FastAPI, Request from fastapi.testclient import TestClient @@ -83,3 +86,37 @@ def test_x_inertia_version_header_name_constant(self): def test_x_inertia_location_header_name_constant(self): assert Header.INERTIA_LOCATION == "X-Inertia-Location" + + +class TestPerRequestRootViewAndVersion(unittest.IsolatedAsyncioTestCase): + def setUp(self): + Inertia._instance = None + + def tearDown(self): + Inertia._instance = None + + async def test_concurrent_requests_do_not_share_root_view_or_version(self): + app = FastAPI() + app.add_middleware(InertiaMiddleware) + both_set = asyncio.Barrier(2) + + @app.get("/{name}") + async def page(name: str): + Inertia.set_root_view(f"{name}.html") + Inertia.version(f"v-{name}") + await both_set.wait() + return {"root_view": Inertia.instance().get_root_view(), "version": Inertia.get_version()} + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + first, second = await asyncio.gather(client.get("/first"), client.get("/second")) + + assert first.json() == {"root_view": "first.html", "version": "v-first"} + assert second.json() == {"root_view": "second.html", "version": "v-second"} + assert Inertia.instance().root_view == "index.html" + assert Inertia.instance().version is None + + def test_set_root_view_and_version_outside_request_configure_defaults(self): + Inertia.set_root_view("app.html") + Inertia.version("v1") + assert Inertia.instance().get_root_view() == "app.html" + assert Inertia.get_version() == "v1" diff --git a/fastapi_startkit/tests/storage/test_local_driver.py b/fastapi_startkit/tests/storage/test_local_driver.py index b472d0f7..84de7449 100644 --- a/fastapi_startkit/tests/storage/test_local_driver.py +++ b/fastapi_startkit/tests/storage/test_local_driver.py @@ -95,6 +95,30 @@ def test_copy_duplicates_file(self, driver, tmp_path): assert src.exists() assert (storage / "copy.txt").read_text() == "copy me" + def test_copy_resolves_relative_paths_against_root(self, driver, tmp_path): + driver.put("orig.txt", "copy me") + driver.copy("orig.txt", "nested/copy.txt") + assert (tmp_path / "storage" / "orig.txt").read_text() == "copy me" + assert (tmp_path / "storage" / "nested" / "copy.txt").read_text() == "copy me" + + def test_get_returns_text_for_utf8_files(self, driver): + driver.put("note.txt", "héllo") + assert driver.get("note.txt") == "héllo" + + def test_get_returns_bytes_for_binary_files(self, driver): + png = b"\x89PNG\r\n\x1a\n\x00\xff" + driver.put("logo.png", png) + assert driver.get("logo.png") == png + + def test_get_bytes_returns_raw_content(self, driver): + driver.put("note.txt", "hello") + driver.put("logo.png", b"\x89PNG\xff") + assert driver.get_bytes("note.txt") == b"hello" + assert driver.get_bytes("logo.png") == b"\x89PNG\xff" + + def test_get_bytes_returns_none_when_missing(self, driver): + assert driver.get_bytes("missing.bin") is None + def test_get_files_lists_files_in_directory(self, driver, tmp_path): storage = tmp_path / "storage" storage.mkdir(parents=True, exist_ok=True) diff --git a/fastapi_startkit/tests/storage/test_s3_driver.py b/fastapi_startkit/tests/storage/test_s3_driver.py index ca5a3285..d5886a18 100644 --- a/fastapi_startkit/tests/storage/test_s3_driver.py +++ b/fastapi_startkit/tests/storage/test_s3_driver.py @@ -77,6 +77,29 @@ class FakeClientError(Exception): result = driver.get("missing.txt") assert result is None + def test_get_returns_bytes_for_binary_content(self, driver, mock_resource): + body = MagicMock() + body.read.return_value = b"\x89PNG\xff" + mock_resource.Bucket().Object().get.return_value = {"Body": body} + + assert driver.get("logo.png") == b"\x89PNG\xff" + + def test_get_bytes_returns_raw_content(self, driver, mock_resource): + body = MagicMock() + body.read.return_value = b"file content" + mock_resource.Bucket().Object().get.return_value = {"Body": body} + + assert driver.get_bytes("file.txt") == b"file content" + + def test_get_bytes_returns_none_on_client_error(self, driver, mock_resource): + class FakeClientError(Exception): + pass + + mock_resource.Bucket().Object().get.side_effect = FakeClientError("not found") + + with patch.object(driver, "missing_file_exceptions", return_value=(FakeClientError,)): + assert driver.get_bytes("missing.txt") is None + # --------------------------------------------------------------------------- # exists @@ -122,6 +145,21 @@ def test_delete_calls_object_delete(self, driver, mock_resource): # --------------------------------------------------------------------------- +class TestS3DriverAppend: + def test_append_writes_existing_content_followed_by_new_content(self, driver, mock_resource): + with patch.object(driver, "get", return_value="hello "): + result = driver.append("log.txt", "world") + + mock_resource.Bucket().put_object.assert_called_with(Key="log.txt", Body="hello world") + assert result == "world" + + def test_append_to_missing_file_writes_new_content(self, driver, mock_resource): + with patch.object(driver, "get", return_value=None): + driver.append("log.txt", "first") + + mock_resource.Bucket().put_object.assert_called_with(Key="log.txt", Body="first") + + class TestS3DriverCopyMove: def test_copy_calls_meta_client_copy(self, driver, mock_resource): driver.copy("src.txt", "dst.txt") diff --git a/fastapi_startkit/tests/storage/test_storage.py b/fastapi_startkit/tests/storage/test_storage.py index 95329178..d226a288 100644 --- a/fastapi_startkit/tests/storage/test_storage.py +++ b/fastapi_startkit/tests/storage/test_storage.py @@ -109,3 +109,15 @@ def test_storage_fake(self): # Verify it uses a temporary directory self.assertIn("storage_fake_s3_", driver.options["root"]) + + def test_storage_copy_and_binary_get_through_facade(self): + with patch("fastapi_startkit.application.app") as mock_app_getter: + mock_app_getter.return_value = self.mock_app + self.mock_app.make.return_value = self.storage_manager + + with Storage.fake("local"): + Storage.put("logo.png", b"\x89PNG\xff") + Storage.copy("logo.png", "copies/logo.png") + + self.assertEqual(Storage.get("copies/logo.png"), b"\x89PNG\xff") + self.assertEqual(Storage.get_bytes("copies/logo.png"), b"\x89PNG\xff") diff --git a/fastapi_startkit/tests/vite/test_request_scope.py b/fastapi_startkit/tests/vite/test_request_scope.py new file mode 100644 index 00000000..cdbe8c3c --- /dev/null +++ b/fastapi_startkit/tests/vite/test_request_scope.py @@ -0,0 +1,187 @@ +import asyncio +import json + +import httpx +import pytest +from fastapi import FastAPI, Request +from starlette.middleware.base import BaseHTTPMiddleware + +from fastapi_startkit.vite import Vite, ViteMiddleware + + +MANIFEST = { + "resources/js/app.js": {"file": "assets/app.js", "src": "resources/js/app.js", "isEntry": True}, + "resources/js/admin.js": {"file": "assets/admin.js", "src": "resources/js/admin.js", "isEntry": True}, +} + + +@pytest.fixture(autouse=True) +def _clear_manifest_cache(): + Vite._manifests.clear() + yield + Vite._manifests.clear() + + +@pytest.fixture +def vite(tmp_path): + build_dir = tmp_path / "build" + build_dir.mkdir() + (build_dir / "manifest.json").write_text(json.dumps(MANIFEST)) + return Vite(str(tmp_path)) + + +def client(app): + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") + + +class LinkHeaderMiddleware(BaseHTTPMiddleware): + def __init__(self, app, vite): + super().__init__(app) + self.vite = vite + + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + response.headers["link"] = ", ".join(sorted(self.vite.preloaded_assets())) + return response + + +def scoped_app(vite, barrier=None): + app = FastAPI() + + @app.get("/{name}") + async def page(name: str, nonce: str | None = None): + if nonce: + vite.use_csp_nonce(nonce) + vite.preloaded_assets()[f"/req-{name}"] = [] + if barrier: + await barrier.wait() + return {"assets": sorted(vite.preloaded_assets()), "nonce": vite.csp_nonce()} + + return app + + +class TestRequestScope: + def test_scope_starts_empty_and_restores_outer_state(self, vite): + vite.preloaded_assets()["/outer"] = [] + with vite.request_scope(): + assert vite.preloaded_assets() == {} + vite.preloaded_assets()["/inner"] = [] + assert vite.preloaded_assets() == {"/outer": []} + + def test_nested_scope_reuses_outer_request_state(self, vite): + with vite.request_scope(): + vite.preloaded_assets()["/a"] = [] + with vite.request_scope(): + assert vite.preloaded_assets() == {"/a": []} + + def test_nonce_set_outside_a_request_is_the_process_default(self, vite): + vite.use_csp_nonce("boot") + with vite.request_scope(): + assert vite.csp_nonce() == "boot" + vite.use_csp_nonce("request") + assert vite.csp_nonce() == "request" + assert vite.csp_nonce() == "boot" + + async def test_nonce_set_in_a_separate_startup_task_reaches_requests(self, vite): + async def lifespan(): + vite.use_csp_nonce("lifespan") + + await asyncio.create_task(lifespan()) + app = scoped_app(vite) + app.add_middleware(ViteMiddleware, vite=vite) + + async with client(app) as http: + response = await http.get("/a") + + assert response.json()["nonce"] == "lifespan" + + +class TestViteMiddleware: + async def test_outer_render_does_not_leak_into_concurrent_requests(self, vite): + vite("resources/js/app.js") + app = scoped_app(vite, asyncio.Barrier(2)) + app.add_middleware(ViteMiddleware, vite=vite) + + async with client(app) as http: + first, second = await asyncio.gather(http.get("/a"), http.get("/b")) + + assert first.json()["assets"] == ["/req-a"] + assert second.json()["assets"] == ["/req-b"] + + async def test_concurrent_requests_keep_their_own_nonce(self, vite): + vite.use_csp_nonce("default") + app = scoped_app(vite, asyncio.Barrier(3)) + app.add_middleware(ViteMiddleware, vite=vite) + + async with client(app) as http: + first, second, third = await asyncio.gather( + http.get("/a", params={"nonce": "nonce-a"}), + http.get("/b", params={"nonce": "nonce-b"}), + http.get("/c"), + ) + + assert first.json()["nonce"] == "nonce-a" + assert second.json()["nonce"] == "nonce-b" + assert third.json()["nonce"] == "default" + assert vite.csp_nonce() == "default" + + async def test_inner_middleware_reads_preloaded_assets_after_call_next(self, vite): + app = FastAPI() + + @app.get("/") + async def page(): + return {"html": vite("resources/js/app.js")} + + app.add_middleware(LinkHeaderMiddleware, vite=vite) + app.add_middleware(ViteMiddleware, vite=vite) + + async with client(app) as http: + response = await http.get("/") + + assert response.headers["link"] == "/build/assets/app.js" + + async def test_outermost_vite_middleware_wraps_middleware_added_after_provider(self, vite): + app = FastAPI() + + @app.get("/") + async def page(): + return {"html": vite("resources/js/admin.js")} + + app.add_middleware(ViteMiddleware, vite=vite) + app.add_middleware(LinkHeaderMiddleware, vite=vite) + app.add_middleware(ViteMiddleware, vite=vite) + + async with client(app) as http: + response = await http.get("/") + + assert response.headers["link"] == "/build/assets/admin.js" + assert vite.preloaded_assets() == {} + + async def test_lifespan_passes_through_without_a_scope(self, vite): + async def inner(scope, receive, send): + vite.preloaded_assets()["/lifespan"] = [] + + await ViteMiddleware(inner, vite=vite)({"type": "lifespan"}, None, None) + + assert vite.preloaded_assets() == {"/lifespan": []} + + async def test_resolves_vite_from_the_container_when_not_given(self, vite, monkeypatch): + container = type("Container", (), {"make": lambda self, key: vite})() + monkeypatch.setattr("fastapi_startkit.application.app", lambda: container) + app = scoped_app(vite) + app.add_middleware(ViteMiddleware) + + async with client(app) as http: + response = await http.get("/a") + + assert response.json()["assets"] == ["/req-a"] + assert vite.preloaded_assets() == {} + + +def test_provider_registers_vite_middleware(tmp_path): + from fastapi_startkit.application import Application + from fastapi_startkit.vite import ViteProvider + + app = Application(base_path=tmp_path, env="testing", providers=[ViteProvider]) + + assert ViteMiddleware in [m.cls for m in app.fastapi.user_middleware] diff --git a/fastapi_startkit/tests/vite/test_vite.py b/fastapi_startkit/tests/vite/test_vite.py index c57bbfb4..9907c77b 100644 --- a/fastapi_startkit/tests/vite/test_vite.py +++ b/fastapi_startkit/tests/vite/test_vite.py @@ -236,6 +236,25 @@ def test_preload_resolver_returning_dict_merges_attributes(self, public_path): html = vite("resources/js/app.js") assert 'data-preload="1"' in html + def test_script_tag_attributes_accept_dict(self, public_path): + write_manifest(public_path) + vite = Vite(public_path).use_script_tag_attributes({"defer": True, "data-track": "reload"}) + html = vite("resources/js/app.js") + assert '