litestar-vite 0.1.22__py3-none-any.whl → 0.2.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 CHANGED
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from litestar_vite import inertia
3
4
  from litestar_vite.config import ViteConfig, ViteTemplateConfig
4
5
  from litestar_vite.loader import ViteAssetLoader
5
6
  from litestar_vite.plugin import VitePlugin
6
7
  from litestar_vite.template_engine import ViteTemplateEngine
7
8
 
8
- __all__ = ("ViteConfig", "ViteTemplateConfig", "VitePlugin", "ViteAssetLoader", "ViteTemplateEngine")
9
+ __all__ = ("ViteConfig", "ViteTemplateConfig", "VitePlugin", "ViteAssetLoader", "ViteTemplateEngine", "inertia")
litestar_vite/cli.py CHANGED
@@ -122,7 +122,7 @@ def vite_init(
122
122
  ctx.obj.app.debug = True
123
123
  env: LitestarEnv = ctx.obj
124
124
  plugin = env.app.plugins.get(VitePlugin)
125
- config = plugin._config
125
+ config = plugin._config # pyright: ignore[reportPrivateUsage]
126
126
 
127
127
  console.rule("[yellow]Initializing Vite[/]", align="left")
128
128
  resource_path = Path(resource_path or config.resource_dir)
@@ -279,3 +279,46 @@ def vite_serve(app: Litestar, verbose: bool) -> None:
279
279
  command_to_run = plugin.config.run_command if plugin.config.hot_reload else plugin.config.build_watch_command
280
280
  execute_command(command_to_run=command_to_run, cwd=plugin.config.root_dir)
281
281
  console.print("[yellow]Vite process stopped.[/]")
282
+
283
+
284
+ @vite_group.command(
285
+ name="generate-routes",
286
+ help="Generate a JSON file with the route configuration",
287
+ )
288
+ @option(
289
+ "--output",
290
+ help="output file path",
291
+ type=ClickPath(dir_okay=False, path_type=Path),
292
+ default=Path("routes.json"),
293
+ show_default=True,
294
+ )
295
+ @option("--verbose", type=bool, help="Enable verbose output.", default=False, is_flag=True)
296
+ def generate_js_routes(app: Litestar, output: Path, verbose: bool) -> None:
297
+ """Run vite serve."""
298
+ import msgspec
299
+ from litestar.cli._utils import (
300
+ LitestarCLIException,
301
+ console,
302
+ )
303
+ from litestar.serialization import encode_json, get_serializer
304
+
305
+ from litestar_vite.plugin import VitePlugin, set_environment
306
+
307
+ if verbose:
308
+ app.debug = True
309
+ serializer = get_serializer(app.type_encoders)
310
+ plugin = app.plugins.get(VitePlugin)
311
+ if plugin.config.set_environment:
312
+ set_environment(config=plugin.config)
313
+ content = msgspec.json.format(
314
+ encode_json(app.openapi_schema.to_schema(), serializer=serializer),
315
+ indent=4,
316
+ )
317
+
318
+ try:
319
+ output.write_bytes(content)
320
+ except OSError as e: # pragma: no cover
321
+ msg = f"failed to write schema to path {output}"
322
+ raise LitestarCLIException(msg) from e
323
+
324
+ console.print("[yellow]Vite process stopped.[/]")
litestar_vite/commands.py CHANGED
@@ -13,11 +13,11 @@ VITE_INIT_TEMPLATES: set[str] = {"package.json.j2", "tsconfig.json.j2", "vite.co
13
13
  DEFAULT_RESOURCES: set[str] = {"styles.css.j2", "main.ts.j2"}
14
14
  DEFAULT_DEV_DEPENDENCIES: dict[str, str] = {
15
15
  "typescript": "^5.3.3",
16
- "vite": "^5.0.6",
17
- "litestar-vite-plugin": "^0.5.1",
18
- "@types/node": "^20.10.3",
16
+ "vite": "^5.3.3",
17
+ "litestar-vite-plugin": "^0.6.2",
18
+ "@types/node": "^20.14.10",
19
19
  }
20
- DEFAULT_DEPENDENCIES: dict[str, str] = {"axios": "^1.6.2"}
20
+ DEFAULT_DEPENDENCIES: dict[str, str] = {"axios": "^1.7.2"}
21
21
 
22
22
 
23
23
  def to_json(value: Any) -> str:
@@ -66,14 +66,15 @@ def init_vite(
66
66
  for template_name in enabled_templates
67
67
  }
68
68
  for template_name, template in templates.items():
69
- target_file_name = template_name.removesuffix(".j2")
69
+ target_file_name = template_name[:-3] if template_name.endswith(".j2") else template_name
70
70
  with Path(target_file_name).open(mode="w") as file:
71
71
  console.print(f" * Writing {target_file_name} to {Path(target_file_name)!s}")
72
72
 
73
73
  file.write(
74
74
  template.render(
75
75
  entry_point=[
76
- f"{resource_path!s}/{resource_name.removesuffix('.j2')}" for resource_name in enabled_resources
76
+ f"{resource_path!s}/{resource_name[:-3] if resource_name.endswith('.j2') else resource_name}"
77
+ for resource_name in enabled_resources
77
78
  ],
78
79
  enable_ssr=enable_ssr,
79
80
  asset_url=asset_url,
@@ -91,9 +92,11 @@ def init_vite(
91
92
 
92
93
  for resource_name in enabled_resources:
93
94
  template = get_template(environment=vite_template_env, name=resource_name)
94
- target_file_name = f"{resource_path}/{resource_name.removesuffix('.j2')}"
95
+ target_file_name = f"{resource_path}/{resource_name[:-3] if resource_name.endswith('.j2') else resource_name}"
95
96
  with Path(target_file_name).open(mode="w") as file:
96
- console.print(f" * Writing {resource_name.removesuffix('.j2')} to {Path(target_file_name)!s}")
97
+ console.print(
98
+ f" * Writing {resource_name[:-3] if resource_name.endswith('.j2') else resource_name} to {Path(target_file_name)!s}",
99
+ )
97
100
  file.write(template.render())
98
101
  console.print("[yellow]Vite initialization completed.[/]")
99
102
 
litestar_vite/config.py CHANGED
@@ -5,21 +5,18 @@ from dataclasses import dataclass, field
5
5
  from functools import cached_property
6
6
  from inspect import isclass
7
7
  from pathlib import Path
8
- from typing import TYPE_CHECKING, Generic, TypeVar, cast
8
+ from typing import TYPE_CHECKING, Any, TypeVar, cast
9
9
 
10
10
  from litestar.exceptions import ImproperlyConfiguredException
11
- from litestar.template import TemplateEngineProtocol
12
-
13
- __all__ = ["ViteConfig", "ViteTemplateConfig"]
14
-
11
+ from litestar.template import TemplateConfig, TemplateEngineProtocol
15
12
 
16
13
  if TYPE_CHECKING:
17
14
  from collections.abc import Callable
18
15
 
19
16
  from litestar.types import PathType
20
17
 
21
-
22
- EngineType = TypeVar("EngineType", bound=TemplateEngineProtocol)
18
+ __all__ = ("ViteConfig", "ViteTemplateConfig")
19
+ EngineType = TypeVar("EngineType", bound=TemplateEngineProtocol[Any, Any])
23
20
 
24
21
 
25
22
  @dataclass
@@ -41,7 +38,7 @@ class ViteConfig:
41
38
 
42
39
  In a standalone Vue or React application, this would be equivalent to the ``./src`` directory.
43
40
  """
44
- template_dir: Path | str | None = field(default=None)
41
+ template_dir: Path | str | None = field(default="templates")
45
42
  """Location of the Jinja2 template file."""
46
43
  public_dir: Path | str = field(default="public")
47
44
  """The optional public directory Vite serves assets from.
@@ -115,7 +112,7 @@ class ViteConfig:
115
112
  self.root_dir = Path(self.root_dir)
116
113
  if self.template_dir is not None and isinstance(self.template_dir, str):
117
114
  self.template_dir = Path(self.template_dir)
118
- if self.public_dir is not None and isinstance(self.public_dir, str):
115
+ if self.public_dir and isinstance(self.public_dir, str):
119
116
  self.public_dir = Path(self.public_dir)
120
117
  if isinstance(self.resource_dir, str):
121
118
  self.resource_dir = Path(self.resource_dir)
@@ -126,7 +123,7 @@ class ViteConfig:
126
123
 
127
124
 
128
125
  @dataclass
129
- class ViteTemplateConfig(Generic[EngineType]):
126
+ class ViteTemplateConfig(TemplateConfig[EngineType]):
130
127
  """Configuration for Templating.
131
128
 
132
129
  To enable templating, pass an instance of this class to the
@@ -134,7 +131,7 @@ class ViteTemplateConfig(Generic[EngineType]):
134
131
  'template_config' key.
135
132
  """
136
133
 
137
- config: ViteConfig
134
+ config: ViteConfig = field(default_factory=lambda: ViteConfig())
138
135
  """A a config for the vite engine`."""
139
136
  engine: type[EngineType] | EngineType | None = field(default=None)
140
137
  """A template engine adhering to the :class:`TemplateEngineProtocol
@@ -150,15 +147,21 @@ class ViteTemplateConfig(Generic[EngineType]):
150
147
 
151
148
  def __post_init__(self) -> None:
152
149
  """Ensure that directory is set if engine is a class."""
153
- if isclass(self.engine) and not self.directory:
150
+ if isclass(self.engine) and not self.directory: # pyright: ignore[reportUnknownMemberType]
154
151
  msg = "directory is a required kwarg when passing a template engine class"
155
152
  raise ImproperlyConfiguredException(msg)
153
+ """Ensure that directory is not set if instance is."""
154
+ if self.instance is not None and self.directory is not None: # pyright: ignore[reportUnknownMemberType]
155
+ msg = "directory cannot be set if instance is"
156
+ raise ImproperlyConfiguredException(msg)
156
157
 
157
158
  def to_engine(self) -> EngineType:
158
159
  """Instantiate the template engine."""
159
160
  template_engine = cast(
160
161
  "EngineType",
161
- self.engine(directory=self.directory, config=self.config) if isclass(self.engine) else self.engine,
162
+ self.engine(directory=self.directory, config=self.config, engine_instance=None) # pyright: ignore[reportUnknownMemberType,reportCallIssue]
163
+ if isclass(self.engine)
164
+ else self.engine,
162
165
  )
163
166
  if callable(self.engine_callback):
164
167
  self.engine_callback(template_engine)
@@ -167,4 +170,4 @@ class ViteTemplateConfig(Generic[EngineType]):
167
170
  @cached_property
168
171
  def engine_instance(self) -> EngineType:
169
172
  """Return the template engine instance."""
170
- return self.to_engine()
173
+ return self.to_engine() if self.instance is None else self.instance
@@ -0,0 +1,31 @@
1
+ from .config import InertiaConfig
2
+ from .middleware import InertiaMiddleware
3
+ from .plugin import InertiaPlugin
4
+ from .request import InertiaDetails, InertiaHeaders, InertiaRequest
5
+ from .response import (
6
+ InertiaBack,
7
+ InertiaExternalRedirect,
8
+ InertiaRedirect,
9
+ InertiaResponse,
10
+ error,
11
+ get_shared_props,
12
+ share,
13
+ )
14
+ from .routes import generate_js_routes
15
+
16
+ __all__ = (
17
+ "InertiaConfig",
18
+ "InertiaDetails",
19
+ "InertiaHeaders",
20
+ "InertiaRequest",
21
+ "InertiaResponse",
22
+ "InertiaExternalRedirect",
23
+ "InertiaPlugin",
24
+ "InertiaBack",
25
+ "share",
26
+ "error",
27
+ "get_shared_props",
28
+ "InertiaRedirect",
29
+ "generate_js_routes",
30
+ "InertiaMiddleware",
31
+ )
@@ -0,0 +1,62 @@
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
+
19
+
20
+ def get_enabled_header(enabled: bool = True) -> dict[str, Any]:
21
+ """True if inertia is enabled."""
22
+
23
+ return {InertiaHeaders.ENABLED.value: "true" if enabled else "false"}
24
+
25
+
26
+ def get_version_header(version: str) -> dict[str, Any]:
27
+ """Return headers for change swap method response."""
28
+ return {InertiaHeaders.VERSION.value: version}
29
+
30
+
31
+ def get_partial_data_header(partial: str) -> dict[str, Any]:
32
+ """Return headers for a partial data response."""
33
+ return {InertiaHeaders.PARTIAL_DATA.value: partial}
34
+
35
+
36
+ def get_partial_component_header(partial: str) -> dict[str, Any]:
37
+ """Return headers for a partial data response."""
38
+ return {InertiaHeaders.PARTIAL_COMPONENT.value: partial}
39
+
40
+
41
+ def get_headers(inertia_headers: InertiaHeaderType) -> dict[str, Any]:
42
+ """Return headers for Inertia responses."""
43
+ if not inertia_headers:
44
+ msg = "Value for inertia_headers cannot be None."
45
+ raise ValueError(msg)
46
+ inertia_headers_dict: dict[str, Callable[..., dict[str, Any]]] = {
47
+ "enabled": get_enabled_header,
48
+ "partial_data": get_partial_data_header,
49
+ "partial_component": get_partial_component_header,
50
+ "version": get_version_header,
51
+ }
52
+
53
+ header: dict[str, Any] = {}
54
+ response: dict[str, Any]
55
+ key: str
56
+ value: Any
57
+
58
+ for key, value in inertia_headers.items():
59
+ if value is not None:
60
+ response = inertia_headers_dict[key](value)
61
+ header.update(response)
62
+ return header
@@ -0,0 +1,25 @@
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
+ extra_page_props: dict[str, Any] = field(default_factory=dict)
25
+ """A dictionary of values to automatically add in to page props on every request."""
@@ -0,0 +1,91 @@
1
+ import re
2
+ from typing import TYPE_CHECKING, Any, cast
3
+
4
+ from litestar import MediaType
5
+ from litestar.connection import Request
6
+ from litestar.connection.base import AuthT, StateT, UserT
7
+ from litestar.exceptions import (
8
+ HTTPException,
9
+ InternalServerException,
10
+ NotFoundException,
11
+ PermissionDeniedException,
12
+ )
13
+ from litestar.exceptions.responses import (
14
+ create_debug_response, # pyright: ignore[reportUnknownVariableType]
15
+ create_exception_response, # pyright: ignore[reportUnknownVariableType]
16
+ )
17
+ from litestar.plugins.flash import flash
18
+ from litestar.repository.exceptions import (
19
+ NotFoundError, # pyright: ignore[reportUnknownVariableType,reportAttributeAccessIssue]
20
+ RepositoryError, # pyright: ignore[reportUnknownVariableType,reportAttributeAccessIssue]
21
+ )
22
+ from litestar.response import Response
23
+ from litestar.status_codes import (
24
+ HTTP_400_BAD_REQUEST,
25
+ HTTP_401_UNAUTHORIZED,
26
+ HTTP_409_CONFLICT,
27
+ HTTP_422_UNPROCESSABLE_ENTITY,
28
+ HTTP_500_INTERNAL_SERVER_ERROR,
29
+ )
30
+
31
+ from litestar_vite.inertia.response import InertiaBack, InertiaRedirect, InertiaResponse, error
32
+
33
+ if TYPE_CHECKING:
34
+ from litestar_vite.inertia.plugin import InertiaPlugin
35
+
36
+ FIELD_ERR_RE = re.compile(r"field `(.+)`$")
37
+
38
+
39
+ class _HTTPConflictException(HTTPException):
40
+ """Request conflict with the current state of the target resource."""
41
+
42
+ status_code = HTTP_409_CONFLICT
43
+
44
+
45
+ def exception_to_http_response(request: Request[UserT, AuthT, StateT], exc: Exception) -> Response[Any]:
46
+ """Handler for all exceptions subclassed from HTTPException."""
47
+ inertia_enabled = getattr(request, "inertia_enabled", False) or getattr(request, "is_inertia", False)
48
+ if isinstance(exc, NotFoundError):
49
+ http_exc = NotFoundException
50
+ elif isinstance(exc, RepositoryError):
51
+ http_exc = _HTTPConflictException # type: ignore[assignment]
52
+ else:
53
+ http_exc = InternalServerException # type: ignore[assignment]
54
+ if not inertia_enabled:
55
+ if request.app.debug and http_exc not in (PermissionDeniedException, NotFoundError):
56
+ return cast("Response[Any]", create_debug_response(request, exc))
57
+ return cast("Response[Any]", create_exception_response(request, http_exc(detail=str(exc.__cause__))))
58
+ is_inertia = getattr(request, "is_inertia", False)
59
+ status_code = getattr(exc, "status_code", HTTP_500_INTERNAL_SERVER_ERROR)
60
+ preferred_type = MediaType.HTML if inertia_enabled and not is_inertia else MediaType.JSON
61
+ detail = getattr(exc, "detail", "") # litestar exceptions
62
+ extras = getattr(exc, "extra", "") # msgspec exceptions
63
+ content = {"status_code": status_code, "message": getattr(exc, "detail", "")}
64
+ inertia_plugin = cast("InertiaPlugin", request.app.plugins.get("InertiaPlugin"))
65
+ if extras:
66
+ content.update({"extra": extras})
67
+ flash(request, detail, category="error")
68
+ if extras and len(extras) >= 1:
69
+ message = extras[0]
70
+ default_field = f"root.{message.get('key')}" if message.get("key", None) is not None else "root" # type: ignore
71
+ error_detail = cast("str", message.get("message", detail)) # type: ignore[union-attr] # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
72
+ match = FIELD_ERR_RE.search(error_detail)
73
+ field = match.group(1) if match else default_field
74
+ if isinstance(message, dict):
75
+ error(request, field, error_detail)
76
+ if status_code in {HTTP_422_UNPROCESSABLE_ENTITY, HTTP_400_BAD_REQUEST}:
77
+ return InertiaBack(request)
78
+ if (
79
+ status_code == HTTP_401_UNAUTHORIZED
80
+ and inertia_plugin.config.redirect_unauthorized_to is not None
81
+ and not request.url.path.startswith(inertia_plugin.config.redirect_unauthorized_to)
82
+ ):
83
+ return InertiaRedirect(
84
+ request,
85
+ redirect_to=inertia_plugin.config.redirect_unauthorized_to,
86
+ )
87
+ return InertiaResponse[Any](
88
+ media_type=preferred_type,
89
+ content=content,
90
+ status_code=status_code,
91
+ )
@@ -0,0 +1,56 @@
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
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
+ template_engine = vite_plugin.template_config.to_engine()
30
+ if inertia_version == template_engine.asset_loader.version_id:
31
+ return None
32
+ return InertiaRedirect(request, redirect_to=str(request.url))
33
+
34
+
35
+ if TYPE_CHECKING:
36
+ from litestar.types import Receive, Scope, Send
37
+
38
+
39
+ class InertiaMiddleware(AbstractMiddleware):
40
+ def __init__(self, app: ASGIApp) -> None:
41
+ super().__init__(app)
42
+ self.app = app
43
+
44
+ async def __call__(
45
+ self,
46
+ scope: "Scope",
47
+ receive: "Receive",
48
+ send: "Send",
49
+ ) -> None:
50
+ request = Request[Any, Any, Any](scope=scope)
51
+ redirect = await redirect_on_asset_version_mismatch(request)
52
+ if redirect is not None:
53
+ response = redirect.to_asgi_response(app=None, request=request) # pyright: ignore[reportUnknownMemberType]
54
+ await response(scope, receive, send)
55
+ else:
56
+ await self.app(scope, receive, send)
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from litestar.exceptions import ImproperlyConfiguredException
6
+ from litestar.middleware import DefineMiddleware
7
+ from litestar.middleware.session import SessionMiddleware
8
+ from litestar.plugins import InitPluginProtocol
9
+ from litestar.security.session_auth.middleware import MiddlewareWrapper
10
+ from litestar.utils.predicates import is_class_and_subclass
11
+
12
+ from litestar_vite.inertia.exception_handler import exception_to_http_response
13
+ from litestar_vite.inertia.middleware import InertiaMiddleware
14
+ from litestar_vite.inertia.request import InertiaRequest
15
+ from litestar_vite.inertia.response import InertiaResponse
16
+ from litestar_vite.inertia.routes import generate_js_routes
17
+
18
+ if TYPE_CHECKING:
19
+ from litestar import Litestar
20
+ from litestar.config.app import AppConfig
21
+
22
+ from litestar_vite.inertia.config import InertiaConfig
23
+
24
+
25
+ def set_js_routes(app: Litestar) -> None:
26
+ """Generate the route structure of the application on startup."""
27
+ js_routes = generate_js_routes(app)
28
+ app.state.js_routes = js_routes
29
+
30
+
31
+ class InertiaPlugin(InitPluginProtocol):
32
+ """Inertia plugin."""
33
+
34
+ __slots__ = ("config",)
35
+
36
+ def __init__(self, config: InertiaConfig) -> None:
37
+ """Initialize ``Inertia``.
38
+
39
+ Args:
40
+ config: configure and start Vite.
41
+ """
42
+ self.config = config
43
+
44
+ def on_app_init(self, app_config: AppConfig) -> AppConfig:
45
+ """Configure application for use with Vite.
46
+
47
+ Args:
48
+ app_config: The :class:`AppConfig <.config.app.AppConfig>` instance.
49
+ """
50
+ for mw in app_config.middleware:
51
+ if isinstance(mw, DefineMiddleware) and is_class_and_subclass(
52
+ mw.middleware,
53
+ (MiddlewareWrapper, SessionMiddleware),
54
+ ):
55
+ break
56
+ else:
57
+ msg = "The Inertia plugin require a session middleware."
58
+ raise ImproperlyConfiguredException(msg)
59
+ app_config.exception_handlers.update({Exception: exception_to_http_response}) # pyright: ignore[reportUnknownMemberType]
60
+ app_config.request_class = InertiaRequest
61
+ app_config.response_class = InertiaResponse
62
+ app_config.middleware.append(InertiaMiddleware)
63
+ app_config.on_startup.append(set_js_routes)
64
+ return app_config
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import cached_property
4
+ from typing import TYPE_CHECKING
5
+ from urllib.parse import unquote
6
+
7
+ from litestar import Request
8
+ from litestar.connection.base import (
9
+ AuthT,
10
+ StateT,
11
+ UserT,
12
+ empty_receive,
13
+ empty_send,
14
+ )
15
+
16
+ from litestar_vite.inertia._utils import InertiaHeaders
17
+
18
+ __all__ = ("InertiaDetails", "InertiaRequest")
19
+
20
+
21
+ if TYPE_CHECKING:
22
+ from litestar.types import Receive, Scope, Send
23
+
24
+
25
+ class InertiaDetails:
26
+ """InertiaDetails holds all the values sent by Inertia client in headers and provide convenient properties."""
27
+
28
+ def __init__(self, request: Request[UserT, AuthT, StateT]) -> None:
29
+ """Initialize :class:`InertiaDetails`"""
30
+ self.request = request
31
+
32
+ def _get_header_value(self, name: InertiaHeaders) -> str | None:
33
+ """Parse request header
34
+
35
+ Check for uri encoded header and unquotes it in readable format.
36
+ """
37
+
38
+ if value := self.request.headers.get(name.value.lower()):
39
+ is_uri_encoded = self.request.headers.get(f"{name.value.lower()}-uri-autoencoded") == "true"
40
+ return unquote(value) if is_uri_encoded else value
41
+ return None
42
+
43
+ def _get_route_component(self) -> str | None:
44
+ """Get the route component.
45
+
46
+ Checks for the `component` key within the route configuration.
47
+ """
48
+ rh = self.request.scope.get("route_handler") # pyright: ignore[reportUnknownMemberType]
49
+ if rh:
50
+ return rh.opt.get("component")
51
+ return None
52
+
53
+ def __bool__(self) -> bool:
54
+ """Check if request is sent by an Inertia client."""
55
+ return self._get_header_value(InertiaHeaders.ENABLED) == "true"
56
+
57
+ @cached_property
58
+ def route_component(self) -> str | None:
59
+ """Partial Data Reload."""
60
+ return self._get_route_component()
61
+
62
+ @cached_property
63
+ def partial_component(self) -> str | None:
64
+ """Partial Data Reload."""
65
+ return self._get_header_value(InertiaHeaders.PARTIAL_COMPONENT)
66
+
67
+ @cached_property
68
+ def partial_data(self) -> str | None:
69
+ """Partial Data Reload."""
70
+ return self._get_header_value(InertiaHeaders.PARTIAL_DATA)
71
+
72
+ @cached_property
73
+ def is_partial_render(self) -> bool:
74
+ """Is Partial Data Reload."""
75
+ return bool(self.partial_component == self.route_component and self.partial_data)
76
+
77
+ @cached_property
78
+ def partial_keys(self) -> list[str]:
79
+ """Is Partial Data Reload."""
80
+ return self.partial_data.split(",") if self.partial_data is not None else []
81
+
82
+
83
+ class InertiaRequest(Request[UserT, AuthT, StateT]):
84
+ """Inertia Request class to work with Inertia client."""
85
+
86
+ __slots__ = (
87
+ "_json",
88
+ "_form",
89
+ "_body",
90
+ "_msgpack",
91
+ "_content_type",
92
+ "_accept",
93
+ "is_connected",
94
+ "supports_push_promise",
95
+ "inertia",
96
+ )
97
+
98
+ def __init__(self, scope: Scope, receive: Receive = empty_receive, send: Send = empty_send) -> None:
99
+ """Initialize :class:`InertiaRequest`"""
100
+ super().__init__(scope=scope, receive=receive, send=send)
101
+ self.inertia = InertiaDetails(self)
102
+
103
+ @property
104
+ def is_inertia(self) -> bool:
105
+ """True if the request contained inertia headers."""
106
+ return bool(self.inertia)
107
+
108
+ @property
109
+ def inertia_enabled(self) -> bool:
110
+ """True if the route handler contains an inertia enabled configuration."""
111
+ return bool(self.inertia.route_component is not None)