litestar-vite 0.12.1__py3-none-any.whl → 0.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of litestar-vite might be problematic. Click here for more details.
- litestar_vite/__init__.py +8 -0
- litestar_vite/__metadata__.py +18 -0
- litestar_vite/cli.py +320 -0
- litestar_vite/commands.py +139 -0
- litestar_vite/config.py +109 -0
- litestar_vite/inertia/__init__.py +37 -0
- litestar_vite/inertia/_utils.py +63 -0
- litestar_vite/inertia/config.py +29 -0
- litestar_vite/inertia/exception_handler.py +117 -0
- litestar_vite/inertia/helpers.py +329 -0
- litestar_vite/inertia/middleware.py +51 -0
- litestar_vite/inertia/plugin.py +96 -0
- litestar_vite/inertia/request.py +116 -0
- litestar_vite/inertia/response.py +319 -0
- litestar_vite/inertia/routes.py +54 -0
- litestar_vite/inertia/types.py +39 -0
- litestar_vite/loader.py +284 -0
- litestar_vite/plugin.py +214 -0
- litestar_vite/py.typed +0 -0
- litestar_vite/templates/__init__.py +0 -0
- litestar_vite/templates/index.html.j2 +16 -0
- litestar_vite/templates/main.ts.j2 +1 -0
- litestar_vite/templates/package.json.j2 +11 -0
- litestar_vite/templates/styles.css.j2 +0 -0
- litestar_vite/templates/tsconfig.json.j2 +30 -0
- litestar_vite/templates/vite.config.ts.j2 +37 -0
- {litestar_vite-0.12.1.dist-info → litestar_vite-0.13.0.dist-info}/METADATA +1 -1
- litestar_vite-0.13.0.dist-info/RECORD +30 -0
- litestar_vite-0.12.1.dist-info/RECORD +0 -4
- {litestar_vite-0.12.1.dist-info → litestar_vite-0.13.0.dist-info}/WHEEL +0 -0
- {litestar_vite-0.12.1.dist-info → litestar_vite-0.13.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from litestar_vite.inertia.types import InertiaHeaderType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InertiaHeaders(str, Enum):
|
|
11
|
+
"""Enum for Inertia Headers"""
|
|
12
|
+
|
|
13
|
+
ENABLED = "X-Inertia"
|
|
14
|
+
VERSION = "X-Inertia-Version"
|
|
15
|
+
PARTIAL_DATA = "X-Inertia-Partial-Data"
|
|
16
|
+
PARTIAL_COMPONENT = "X-Inertia-Partial-Component"
|
|
17
|
+
LOCATION = "X-Inertia-Location"
|
|
18
|
+
REFERER = "Referer"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_enabled_header(enabled: bool = True) -> dict[str, Any]:
|
|
22
|
+
"""True if inertia is enabled."""
|
|
23
|
+
|
|
24
|
+
return {InertiaHeaders.ENABLED.value: "true" if enabled else "false"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_version_header(version: str) -> dict[str, Any]:
|
|
28
|
+
"""Return headers for change swap method response."""
|
|
29
|
+
return {InertiaHeaders.VERSION.value: version}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_partial_data_header(partial: str) -> dict[str, Any]:
|
|
33
|
+
"""Return headers for a partial data response."""
|
|
34
|
+
return {InertiaHeaders.PARTIAL_DATA.value: partial}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_partial_component_header(partial: str) -> dict[str, Any]:
|
|
38
|
+
"""Return headers for a partial data response."""
|
|
39
|
+
return {InertiaHeaders.PARTIAL_COMPONENT.value: partial}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_headers(inertia_headers: InertiaHeaderType) -> dict[str, Any]:
|
|
43
|
+
"""Return headers for Inertia responses."""
|
|
44
|
+
if not inertia_headers:
|
|
45
|
+
msg = "Value for inertia_headers cannot be None."
|
|
46
|
+
raise ValueError(msg)
|
|
47
|
+
inertia_headers_dict: dict[str, Callable[..., dict[str, Any]]] = {
|
|
48
|
+
"enabled": get_enabled_header,
|
|
49
|
+
"partial_data": get_partial_data_header,
|
|
50
|
+
"partial_component": get_partial_component_header,
|
|
51
|
+
"version": get_version_header,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
header: dict[str, Any] = {}
|
|
55
|
+
response: dict[str, Any]
|
|
56
|
+
key: str
|
|
57
|
+
value: Any
|
|
58
|
+
|
|
59
|
+
for key, value in inertia_headers.items():
|
|
60
|
+
if value is not None:
|
|
61
|
+
response = inertia_headers_dict[key](value)
|
|
62
|
+
header.update(response)
|
|
63
|
+
return header
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
__all__ = ("InertiaConfig",)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class InertiaConfig:
|
|
11
|
+
"""Configuration for InertiaJS support."""
|
|
12
|
+
|
|
13
|
+
root_template: str = "index.html"
|
|
14
|
+
"""Name of the root template to use.
|
|
15
|
+
|
|
16
|
+
This must be a path that is found by the Vite Plugin template config
|
|
17
|
+
"""
|
|
18
|
+
component_opt_key: str = "component"
|
|
19
|
+
"""An identifier to use on routes to get the inertia component to render."""
|
|
20
|
+
exclude_from_js_routes_key: str = "exclude_from_routes"
|
|
21
|
+
"""An identifier to use on routes to exclude a route from the generated routes typescript file."""
|
|
22
|
+
redirect_unauthorized_to: str | None = None
|
|
23
|
+
"""Optionally supply a path where unauthorized requests should redirect."""
|
|
24
|
+
redirect_404: str | None = None
|
|
25
|
+
"""Optionally supply a path where 404 requests should redirect."""
|
|
26
|
+
extra_static_page_props: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
"""A dictionary of values to automatically add in to page props on every response."""
|
|
28
|
+
extra_session_page_props: set[str] = field(default_factory=set)
|
|
29
|
+
"""A set of session keys for which the value automatically be added (if it exists) to the response."""
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
5
|
+
|
|
6
|
+
from litestar import MediaType
|
|
7
|
+
from litestar.connection import Request
|
|
8
|
+
from litestar.connection.base import AuthT, StateT, UserT
|
|
9
|
+
from litestar.exceptions import (
|
|
10
|
+
HTTPException,
|
|
11
|
+
ImproperlyConfiguredException,
|
|
12
|
+
InternalServerException,
|
|
13
|
+
NotAuthorizedException,
|
|
14
|
+
NotFoundException,
|
|
15
|
+
PermissionDeniedException,
|
|
16
|
+
)
|
|
17
|
+
from litestar.exceptions.responses import (
|
|
18
|
+
create_debug_response, # pyright: ignore[reportUnknownVariableType]
|
|
19
|
+
create_exception_response, # pyright: ignore[reportUnknownVariableType]
|
|
20
|
+
)
|
|
21
|
+
from litestar.plugins.flash import flash
|
|
22
|
+
from litestar.repository.exceptions import (
|
|
23
|
+
ConflictError, # pyright: ignore[reportUnknownVariableType,reportAttributeAccessIssue]
|
|
24
|
+
NotFoundError, # pyright: ignore[reportUnknownVariableType,reportAttributeAccessIssue]
|
|
25
|
+
RepositoryError, # pyright: ignore[reportUnknownVariableType,reportAttributeAccessIssue]
|
|
26
|
+
)
|
|
27
|
+
from litestar.response import Response
|
|
28
|
+
from litestar.status_codes import (
|
|
29
|
+
HTTP_400_BAD_REQUEST,
|
|
30
|
+
HTTP_401_UNAUTHORIZED,
|
|
31
|
+
HTTP_404_NOT_FOUND,
|
|
32
|
+
HTTP_405_METHOD_NOT_ALLOWED,
|
|
33
|
+
HTTP_409_CONFLICT,
|
|
34
|
+
HTTP_422_UNPROCESSABLE_ENTITY,
|
|
35
|
+
HTTP_500_INTERNAL_SERVER_ERROR,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from litestar_vite.inertia.helpers import error
|
|
39
|
+
from litestar_vite.inertia.response import InertiaBack, InertiaRedirect, InertiaResponse
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from litestar.connection import Request
|
|
43
|
+
from litestar.connection.base import AuthT, StateT, UserT
|
|
44
|
+
from litestar.response import Response
|
|
45
|
+
|
|
46
|
+
from litestar_vite.inertia.plugin import InertiaPlugin
|
|
47
|
+
|
|
48
|
+
FIELD_ERR_RE = re.compile(r"field `(.+)`$")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _HTTPConflictException(HTTPException):
|
|
52
|
+
"""Request conflict with the current state of the target resource."""
|
|
53
|
+
|
|
54
|
+
status_code: int = HTTP_409_CONFLICT
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def exception_to_http_response(request: Request[UserT, AuthT, StateT], exc: Exception) -> Response[Any]:
|
|
58
|
+
"""Handler for all exceptions subclassed from HTTPException."""
|
|
59
|
+
inertia_enabled = getattr(request, "inertia_enabled", False) or getattr(request, "is_inertia", False)
|
|
60
|
+
|
|
61
|
+
if not inertia_enabled:
|
|
62
|
+
if isinstance(exc, NotFoundError):
|
|
63
|
+
http_exc = NotFoundException
|
|
64
|
+
elif isinstance(exc, (RepositoryError, ConflictError)):
|
|
65
|
+
http_exc = _HTTPConflictException # type: ignore[assignment]
|
|
66
|
+
else:
|
|
67
|
+
http_exc = InternalServerException # type: ignore[assignment]
|
|
68
|
+
if request.app.debug and http_exc not in (PermissionDeniedException, NotFoundError):
|
|
69
|
+
return cast("Response[Any]", create_debug_response(request, exc))
|
|
70
|
+
return cast("Response[Any]", create_exception_response(request, http_exc(detail=str(exc.__cause__)))) # pyright: ignore[reportUnknownArgumentType]
|
|
71
|
+
return create_inertia_exception_response(request, exc)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def create_inertia_exception_response(request: Request[UserT, AuthT, StateT], exc: Exception) -> Response[Any]:
|
|
75
|
+
"""Create the inertia exception response"""
|
|
76
|
+
is_inertia = getattr(request, "is_inertia", False)
|
|
77
|
+
status_code = getattr(exc, "status_code", HTTP_500_INTERNAL_SERVER_ERROR)
|
|
78
|
+
preferred_type = MediaType.HTML if not is_inertia else MediaType.JSON
|
|
79
|
+
detail = getattr(exc, "detail", "") # litestar exceptions
|
|
80
|
+
extras = getattr(exc, "extra", "") # msgspec exceptions
|
|
81
|
+
content: dict[str, Any] = {"status_code": status_code, "message": getattr(exc, "detail", "")}
|
|
82
|
+
inertia_plugin = cast("InertiaPlugin", request.app.plugins.get("InertiaPlugin"))
|
|
83
|
+
if extras:
|
|
84
|
+
content.update({"extra": extras})
|
|
85
|
+
try:
|
|
86
|
+
flash(request, detail, category="error")
|
|
87
|
+
except (AttributeError, ImproperlyConfiguredException):
|
|
88
|
+
msg = "Unable to set `flash` session state. A valid session was not found for this request."
|
|
89
|
+
request.logger.warning(msg)
|
|
90
|
+
if extras and len(extras) >= 1:
|
|
91
|
+
message = extras[0]
|
|
92
|
+
default_field = f"root.{message.get('key')}" if message.get("key", None) is not None else "root" # type: ignore
|
|
93
|
+
error_detail = cast("str", message.get("message", detail)) # type: ignore[union-attr] # pyright: ignore[reportUnknownMemberType]
|
|
94
|
+
match = FIELD_ERR_RE.search(error_detail)
|
|
95
|
+
field = match.group(1) if match else default_field
|
|
96
|
+
if isinstance(message, dict):
|
|
97
|
+
error(request, field, error_detail if error_detail else detail)
|
|
98
|
+
if status_code in {HTTP_422_UNPROCESSABLE_ENTITY, HTTP_400_BAD_REQUEST}:
|
|
99
|
+
return InertiaBack(request)
|
|
100
|
+
if isinstance(exc, PermissionDeniedException):
|
|
101
|
+
return InertiaBack(request)
|
|
102
|
+
if (status_code == HTTP_401_UNAUTHORIZED or isinstance(exc, NotAuthorizedException)) and (
|
|
103
|
+
inertia_plugin.config.redirect_unauthorized_to is not None
|
|
104
|
+
and request.url.path != inertia_plugin.config.redirect_unauthorized_to
|
|
105
|
+
):
|
|
106
|
+
return InertiaRedirect(request, redirect_to=inertia_plugin.config.redirect_unauthorized_to)
|
|
107
|
+
|
|
108
|
+
if status_code in {HTTP_404_NOT_FOUND, HTTP_405_METHOD_NOT_ALLOWED} and (
|
|
109
|
+
inertia_plugin.config.redirect_404 is not None and request.url.path != inertia_plugin.config.redirect_404
|
|
110
|
+
):
|
|
111
|
+
return InertiaRedirect(request, redirect_to=inertia_plugin.config.redirect_404)
|
|
112
|
+
|
|
113
|
+
return InertiaResponse[Any](
|
|
114
|
+
media_type=preferred_type,
|
|
115
|
+
content=content,
|
|
116
|
+
status_code=status_code,
|
|
117
|
+
)
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from textwrap import dedent
|
|
9
|
+
from typing import (
|
|
10
|
+
TYPE_CHECKING,
|
|
11
|
+
Any,
|
|
12
|
+
Callable,
|
|
13
|
+
Coroutine,
|
|
14
|
+
Dict,
|
|
15
|
+
Generator,
|
|
16
|
+
Generic,
|
|
17
|
+
Iterable,
|
|
18
|
+
List,
|
|
19
|
+
TypeVar,
|
|
20
|
+
cast,
|
|
21
|
+
overload,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from anyio.from_thread import BlockingPortal, start_blocking_portal
|
|
25
|
+
from litestar.exceptions import ImproperlyConfiguredException
|
|
26
|
+
from litestar.utils.empty import value_or_default
|
|
27
|
+
from litestar.utils.scope.state import ScopeState
|
|
28
|
+
from markupsafe import Markup
|
|
29
|
+
from typing_extensions import ParamSpec, TypeGuard
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from litestar.connection import ASGIConnection
|
|
33
|
+
|
|
34
|
+
from litestar_vite.inertia.plugin import InertiaPlugin
|
|
35
|
+
from litestar_vite.inertia.routes import Routes
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T")
|
|
38
|
+
T_ParamSpec = ParamSpec("T_ParamSpec")
|
|
39
|
+
PropKeyT = TypeVar("PropKeyT", bound=str)
|
|
40
|
+
StaticT = TypeVar("StaticT", bound=object)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@overload
|
|
44
|
+
def lazy(key: str, value_or_callable: None) -> StaticProp[str, None]: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@overload
|
|
48
|
+
def lazy(key: str, value_or_callable: T) -> StaticProp[str, T]: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@overload
|
|
52
|
+
def lazy(key: str, value_or_callable: Callable[..., None] = ...) -> DeferredProp[str, None]: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@overload
|
|
56
|
+
def lazy(key: str, value_or_callable: Callable[..., Coroutine[Any, Any, None]] = ...) -> DeferredProp[str, None]: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@overload
|
|
60
|
+
def lazy(
|
|
61
|
+
key: str,
|
|
62
|
+
value_or_callable: Callable[..., T | Coroutine[Any, Any, T]] = ..., # pyright: ignore[reportInvalidTypeVarUse]
|
|
63
|
+
) -> DeferredProp[str, T]: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def lazy(
|
|
67
|
+
key: str,
|
|
68
|
+
value_or_callable: None
|
|
69
|
+
| Callable[T_ParamSpec, None | Coroutine[Any, Any, None]]
|
|
70
|
+
| T
|
|
71
|
+
| Callable[T_ParamSpec, T | Coroutine[Any, Any, T]] = None,
|
|
72
|
+
) -> StaticProp[str, None] | StaticProp[str, T] | DeferredProp[str, T] | DeferredProp[str, None]:
|
|
73
|
+
"""Wrap an async function to return a DeferredProp."""
|
|
74
|
+
if value_or_callable is None:
|
|
75
|
+
return StaticProp[str, None](key=key, value=None)
|
|
76
|
+
|
|
77
|
+
if not callable(value_or_callable):
|
|
78
|
+
return StaticProp[str, T](key=key, value=value_or_callable)
|
|
79
|
+
|
|
80
|
+
return DeferredProp[str, T](key=key, value=cast("Callable[..., T | Coroutine[Any, Any, T]]", value_or_callable))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class StaticProp(Generic[PropKeyT, StaticT]):
|
|
84
|
+
"""A wrapper for static property evaluation."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, key: PropKeyT, value: StaticT) -> None:
|
|
87
|
+
self._key = key
|
|
88
|
+
self._result = value
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def key(self) -> PropKeyT:
|
|
92
|
+
return self._key
|
|
93
|
+
|
|
94
|
+
def render(self, portal: BlockingPortal | None = None) -> StaticT:
|
|
95
|
+
return self._result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class DeferredProp(Generic[PropKeyT, T]):
|
|
99
|
+
"""A wrapper for deferred property evaluation."""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self, key: PropKeyT, value: Callable[..., None | T | Coroutine[Any, Any, T | None]] | None = None
|
|
103
|
+
) -> None:
|
|
104
|
+
self._key = key
|
|
105
|
+
self._value = value
|
|
106
|
+
self._evaluated = False
|
|
107
|
+
self._result: T | None = None
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def key(self) -> PropKeyT:
|
|
111
|
+
return self._key
|
|
112
|
+
|
|
113
|
+
@contextmanager
|
|
114
|
+
def with_portal(self, portal: BlockingPortal | None = None) -> Generator[BlockingPortal, None, None]:
|
|
115
|
+
if portal is None:
|
|
116
|
+
with start_blocking_portal() as p:
|
|
117
|
+
yield p
|
|
118
|
+
else:
|
|
119
|
+
yield portal
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _is_awaitable(
|
|
123
|
+
v: Callable[..., T | Coroutine[Any, Any, T]],
|
|
124
|
+
) -> TypeGuard[Coroutine[Any, Any, T]]:
|
|
125
|
+
return inspect.iscoroutinefunction(v)
|
|
126
|
+
|
|
127
|
+
def render(self, portal: BlockingPortal | None = None) -> T | None:
|
|
128
|
+
if self._evaluated:
|
|
129
|
+
return self._result
|
|
130
|
+
if self._value is None or not callable(self._value):
|
|
131
|
+
self._result = self._value
|
|
132
|
+
self._evaluated = True
|
|
133
|
+
return self._result
|
|
134
|
+
if not self._is_awaitable(cast("Callable[..., T]", self._value)):
|
|
135
|
+
self._result = cast("T", self._value())
|
|
136
|
+
self._evaluated = True
|
|
137
|
+
return self._result
|
|
138
|
+
with self.with_portal(portal) as p:
|
|
139
|
+
self._result = p.call(cast("Callable[..., T]", self._value))
|
|
140
|
+
self._evaluated = True
|
|
141
|
+
return self._result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def is_lazy_prop(value: Any) -> TypeGuard[DeferredProp[Any, Any]]:
|
|
145
|
+
"""Check if value is a deferred property.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
value: Any value to check
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
bool: True if value is a deferred property
|
|
152
|
+
"""
|
|
153
|
+
return isinstance(value, (DeferredProp, StaticProp))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def should_render(value: Any, partial_data: set[str] | None = None) -> bool:
|
|
157
|
+
"""Check if value should be rendered.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
value: Any value to check
|
|
161
|
+
partial_data: Optional set of keys for partial rendering
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
bool: True if value should be rendered
|
|
165
|
+
"""
|
|
166
|
+
partial_data = partial_data or set()
|
|
167
|
+
if is_lazy_prop(value):
|
|
168
|
+
return value.key in partial_data
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def is_or_contains_lazy_prop(value: Any) -> bool:
|
|
173
|
+
"""Check if value is or contains a deferred property.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
value: Any value to check
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
bool: True if value is or contains a deferred property
|
|
180
|
+
"""
|
|
181
|
+
if is_lazy_prop(value):
|
|
182
|
+
return True
|
|
183
|
+
if isinstance(value, str):
|
|
184
|
+
return False
|
|
185
|
+
if isinstance(value, Mapping):
|
|
186
|
+
return any(is_or_contains_lazy_prop(v) for v in cast("Mapping[str, Any]", value).values())
|
|
187
|
+
if isinstance(value, Iterable):
|
|
188
|
+
return any(is_or_contains_lazy_prop(v) for v in cast("Iterable[Any]", value))
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def lazy_render(value: T, partial_data: set[str] | None = None, portal: BlockingPortal | None = None) -> T:
|
|
193
|
+
"""Filter deferred properties from the value based on partial data.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
value: The value to filter
|
|
197
|
+
partial_data: Keys for partial rendering
|
|
198
|
+
portal: Optional portal to use for async rendering
|
|
199
|
+
Returns:
|
|
200
|
+
The filtered value
|
|
201
|
+
"""
|
|
202
|
+
partial_data = partial_data or set()
|
|
203
|
+
if isinstance(value, str):
|
|
204
|
+
return cast("T", value)
|
|
205
|
+
if isinstance(value, Mapping):
|
|
206
|
+
return cast(
|
|
207
|
+
"T",
|
|
208
|
+
{
|
|
209
|
+
k: lazy_render(v, partial_data, portal)
|
|
210
|
+
for k, v in cast("Mapping[str, Any]", value).items()
|
|
211
|
+
if should_render(v, partial_data)
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if isinstance(value, (list, tuple)):
|
|
216
|
+
filtered = [
|
|
217
|
+
lazy_render(v, partial_data, portal) for v in cast("Iterable[Any]", value) if should_render(v, partial_data)
|
|
218
|
+
]
|
|
219
|
+
return cast("T", type(value)(filtered)) # pyright: ignore[reportUnknownArgumentType]
|
|
220
|
+
|
|
221
|
+
if is_lazy_prop(value) and should_render(value, partial_data):
|
|
222
|
+
return cast("T", value.render(portal))
|
|
223
|
+
|
|
224
|
+
return cast("T", value)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def get_shared_props(
|
|
228
|
+
request: ASGIConnection[Any, Any, Any, Any],
|
|
229
|
+
partial_data: set[str] | None = None,
|
|
230
|
+
) -> dict[str, Any]:
|
|
231
|
+
"""Return shared session props for a request.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
request: The ASGI connection.
|
|
235
|
+
partial_data: Optional set of keys for partial rendering.
|
|
236
|
+
portal: Optional portal to use for async rendering
|
|
237
|
+
Returns:
|
|
238
|
+
Dict[str, Any]: The shared props.
|
|
239
|
+
|
|
240
|
+
Note:
|
|
241
|
+
Be sure to call this before `self.create_template_context` if you would like to include the `flash` message details.
|
|
242
|
+
"""
|
|
243
|
+
props: dict[str, Any] = {}
|
|
244
|
+
flash: dict[str, list[str]] = defaultdict(list)
|
|
245
|
+
errors: dict[str, Any] = {}
|
|
246
|
+
error_bag = request.headers.get("X-Inertia-Error-Bag", None)
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
errors = request.session.pop("_errors", {})
|
|
250
|
+
shared_props = cast("Dict[str,Any]", request.session.pop("_shared", {}))
|
|
251
|
+
inertia_plugin = cast("InertiaPlugin", request.app.plugins.get("InertiaPlugin"))
|
|
252
|
+
|
|
253
|
+
# Handle deferred props
|
|
254
|
+
for key, value in shared_props.items():
|
|
255
|
+
if is_lazy_prop(value) and should_render(value, partial_data):
|
|
256
|
+
props[key] = value.render(inertia_plugin.portal)
|
|
257
|
+
continue
|
|
258
|
+
if should_render(value, partial_data):
|
|
259
|
+
props[key] = value
|
|
260
|
+
|
|
261
|
+
for message in cast("List[Dict[str,Any]]", request.session.pop("_messages", [])):
|
|
262
|
+
flash[message["category"]].append(message["message"])
|
|
263
|
+
|
|
264
|
+
props.update(inertia_plugin.config.extra_static_page_props)
|
|
265
|
+
for session_prop in inertia_plugin.config.extra_session_page_props:
|
|
266
|
+
if session_prop not in props and session_prop in request.session:
|
|
267
|
+
props[session_prop] = request.session.get(session_prop)
|
|
268
|
+
|
|
269
|
+
except (AttributeError, ImproperlyConfiguredException):
|
|
270
|
+
msg = "Unable to generate all shared props. A valid session was not found for this request."
|
|
271
|
+
request.logger.warning(msg)
|
|
272
|
+
|
|
273
|
+
props["flash"] = flash
|
|
274
|
+
props["errors"] = {error_bag: errors} if error_bag is not None else errors
|
|
275
|
+
props["csrf_token"] = value_or_default(ScopeState.from_scope(request.scope).csrf_token, "")
|
|
276
|
+
return props
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def share(
|
|
280
|
+
connection: ASGIConnection[Any, Any, Any, Any],
|
|
281
|
+
key: str,
|
|
282
|
+
value: Any,
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Share a value in the session.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
connection: The ASGI connection.
|
|
288
|
+
key: The key to store the value under.
|
|
289
|
+
value: The value to store.
|
|
290
|
+
"""
|
|
291
|
+
try:
|
|
292
|
+
connection.session.setdefault("_shared", {}).update({key: value})
|
|
293
|
+
except (AttributeError, ImproperlyConfiguredException):
|
|
294
|
+
msg = "Unable to set `share` session state. A valid session was not found for this request."
|
|
295
|
+
connection.logger.warning(msg)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def error(
|
|
299
|
+
connection: ASGIConnection[Any, Any, Any, Any],
|
|
300
|
+
key: str,
|
|
301
|
+
message: str,
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Set an error message in the session.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
connection: The ASGI connection.
|
|
307
|
+
key: The key to store the error under.
|
|
308
|
+
message: The error message.
|
|
309
|
+
"""
|
|
310
|
+
try:
|
|
311
|
+
connection.session.setdefault("_errors", {}).update({key: message})
|
|
312
|
+
except (AttributeError, ImproperlyConfiguredException):
|
|
313
|
+
msg = "Unable to set `error` session state. A valid session was not found for this request."
|
|
314
|
+
connection.logger.warning(msg)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def js_routes_script(js_routes: Routes) -> Markup:
|
|
318
|
+
@lru_cache
|
|
319
|
+
def _markup_safe_json_dumps(js_routes: str) -> Markup:
|
|
320
|
+
js = js_routes.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026").replace("'", "\\u0027")
|
|
321
|
+
return Markup(js)
|
|
322
|
+
|
|
323
|
+
return Markup(
|
|
324
|
+
dedent(f"""
|
|
325
|
+
<script type="module">
|
|
326
|
+
globalThis.routes = JSON.parse('{_markup_safe_json_dumps(js_routes.formatted_routes)}')
|
|
327
|
+
</script>
|
|
328
|
+
"""),
|
|
329
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from litestar import Request
|
|
6
|
+
from litestar.middleware import AbstractMiddleware
|
|
7
|
+
from litestar.types import Receive, Scope, Send
|
|
8
|
+
|
|
9
|
+
from litestar_vite.inertia.response import InertiaRedirect
|
|
10
|
+
from litestar_vite.plugin import VitePlugin
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from litestar.connection.base import (
|
|
14
|
+
AuthT,
|
|
15
|
+
StateT,
|
|
16
|
+
UserT,
|
|
17
|
+
)
|
|
18
|
+
from litestar.types import ASGIApp, Receive, Scope, Send
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def redirect_on_asset_version_mismatch(request: Request[UserT, AuthT, StateT]) -> InertiaRedirect | None:
|
|
22
|
+
if getattr(request, "is_inertia", None) is None:
|
|
23
|
+
return None
|
|
24
|
+
inertia_version = request.headers.get("X-Inertia-Version")
|
|
25
|
+
if inertia_version is None:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
vite_plugin = request.app.plugins.get(VitePlugin)
|
|
29
|
+
if inertia_version == vite_plugin.asset_loader.version_id:
|
|
30
|
+
return None
|
|
31
|
+
return InertiaRedirect(request, redirect_to=str(request.url))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InertiaMiddleware(AbstractMiddleware):
|
|
35
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
36
|
+
super().__init__(app)
|
|
37
|
+
self.app = app
|
|
38
|
+
|
|
39
|
+
async def __call__(
|
|
40
|
+
self,
|
|
41
|
+
scope: "Scope",
|
|
42
|
+
receive: "Receive",
|
|
43
|
+
send: "Send",
|
|
44
|
+
) -> None:
|
|
45
|
+
request = Request[Any, Any, Any](scope=scope)
|
|
46
|
+
redirect = await redirect_on_asset_version_mismatch(request)
|
|
47
|
+
if redirect is not None:
|
|
48
|
+
response = redirect.to_asgi_response(app=None, request=request) # pyright: ignore[reportUnknownMemberType]
|
|
49
|
+
await response(scope, receive, send)
|
|
50
|
+
else:
|
|
51
|
+
await self.app(scope, receive, send)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import TYPE_CHECKING, AsyncGenerator
|
|
5
|
+
|
|
6
|
+
from anyio.from_thread import start_blocking_portal
|
|
7
|
+
from litestar.plugins import InitPluginProtocol
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from anyio.from_thread import BlockingPortal
|
|
11
|
+
from litestar import Litestar
|
|
12
|
+
from litestar.config.app import AppConfig
|
|
13
|
+
|
|
14
|
+
from litestar_vite.inertia.config import InertiaConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def set_js_routes(app: Litestar) -> None:
|
|
18
|
+
"""Generate the route structure of the application on startup."""
|
|
19
|
+
from litestar_vite.inertia.routes import generate_js_routes
|
|
20
|
+
|
|
21
|
+
js_routes = generate_js_routes(app)
|
|
22
|
+
app.state.js_routes = js_routes
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InertiaPlugin(InitPluginProtocol):
|
|
26
|
+
"""Inertia plugin."""
|
|
27
|
+
|
|
28
|
+
__slots__ = ("_portal", "config")
|
|
29
|
+
|
|
30
|
+
def __init__(self, config: InertiaConfig) -> None:
|
|
31
|
+
"""Initialize ``Inertia``.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
config: Inertia configuration.
|
|
35
|
+
"""
|
|
36
|
+
self.config = config
|
|
37
|
+
|
|
38
|
+
@asynccontextmanager
|
|
39
|
+
async def lifespan(self, app: Litestar) -> AsyncGenerator[None, None]:
|
|
40
|
+
"""Lifespan to ensure the event loop is available."""
|
|
41
|
+
|
|
42
|
+
with start_blocking_portal() as portal:
|
|
43
|
+
self._portal = portal
|
|
44
|
+
yield
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def portal(self) -> BlockingPortal:
|
|
48
|
+
"""Get the portal."""
|
|
49
|
+
return self._portal
|
|
50
|
+
|
|
51
|
+
def on_app_init(self, app_config: AppConfig) -> AppConfig:
|
|
52
|
+
"""Configure application for use with Vite.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
app_config: The :class:`AppConfig <litestar.config.app.AppConfig>` instance.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from litestar.exceptions import ImproperlyConfiguredException
|
|
59
|
+
from litestar.middleware import DefineMiddleware
|
|
60
|
+
from litestar.middleware.session import SessionMiddleware
|
|
61
|
+
from litestar.security.session_auth.middleware import MiddlewareWrapper
|
|
62
|
+
from litestar.utils.predicates import is_class_and_subclass
|
|
63
|
+
|
|
64
|
+
from litestar_vite.inertia.exception_handler import exception_to_http_response
|
|
65
|
+
from litestar_vite.inertia.helpers import DeferredProp, StaticProp
|
|
66
|
+
from litestar_vite.inertia.middleware import InertiaMiddleware
|
|
67
|
+
from litestar_vite.inertia.request import InertiaRequest
|
|
68
|
+
from litestar_vite.inertia.response import InertiaBack, InertiaResponse
|
|
69
|
+
|
|
70
|
+
for mw in app_config.middleware:
|
|
71
|
+
if isinstance(mw, DefineMiddleware) and is_class_and_subclass(
|
|
72
|
+
mw.middleware,
|
|
73
|
+
(MiddlewareWrapper, SessionMiddleware),
|
|
74
|
+
):
|
|
75
|
+
break
|
|
76
|
+
else:
|
|
77
|
+
msg = "The Inertia plugin require a session middleware."
|
|
78
|
+
raise ImproperlyConfiguredException(msg)
|
|
79
|
+
app_config.exception_handlers.update({Exception: exception_to_http_response}) # pyright: ignore[reportUnknownMemberType]
|
|
80
|
+
app_config.request_class = InertiaRequest
|
|
81
|
+
app_config.response_class = InertiaResponse
|
|
82
|
+
app_config.middleware.append(InertiaMiddleware)
|
|
83
|
+
app_config.on_startup.append(set_js_routes)
|
|
84
|
+
app_config.signature_types.extend([InertiaRequest, InertiaResponse, InertiaBack, StaticProp, DeferredProp])
|
|
85
|
+
app_config.type_encoders = {
|
|
86
|
+
StaticProp: lambda val: val.render(),
|
|
87
|
+
DeferredProp: lambda val: val.render(),
|
|
88
|
+
**(app_config.type_encoders or {}),
|
|
89
|
+
}
|
|
90
|
+
app_config.type_decoders = [
|
|
91
|
+
(lambda x: x is StaticProp, lambda t, v: t(v)),
|
|
92
|
+
(lambda x: x is DeferredProp, lambda t, v: t(v)),
|
|
93
|
+
*(app_config.type_decoders or []),
|
|
94
|
+
]
|
|
95
|
+
app_config.lifespan.append(self.lifespan) # pyright: ignore[reportUnknownMemberType]
|
|
96
|
+
return app_config
|