patera-joltui 0.2.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marko Šterk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: patera-joltui
3
+ Version: 0.2.0
4
+ Summary: JolUI server component integration for Patera
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Dist: patera>=0.122
8
+ Requires-Python: >=3.13
9
+ Description-Content-Type: text/markdown
10
+
11
+ # patera-joltui
12
+
13
+ Install `patera-joltui` (or `patera[joltui]`) and import from `patera.joltui`.
14
+
15
+ ```python
16
+ from html import escape
17
+ from patera import Request
18
+ from patera.injectable import Inject
19
+ from patera.joltui import (
20
+ ComponentGroup, JoltuiGroupHandler, PateraJoltui,
21
+ component, group, joltui_request,
22
+ )
23
+
24
+ @group("default")
25
+ class DefaultGroupHandler(JoltuiGroupHandler):
26
+ @component("home-page")
27
+ async def home_page(self, req: Request):
28
+ return req.res.html_body("<h1>Welcome</h1>")
29
+
30
+ @group("user-group")
31
+ class UserGroupHandler(JoltuiGroupHandler):
32
+ @component("user-page")
33
+ async def user_page(self, req: Request):
34
+ data = joltui_request(req)
35
+ user_id = escape(str(data.route_parameters["user_id"]))
36
+ return req.res.html_body(f"<h1>User {user_id}</h1>")
37
+
38
+ class JoltuiInterface(PateraJoltui):
39
+ default_group: ComponentGroup[DefaultGroupHandler]
40
+ user_group: ComponentGroup[UserGroupHandler]
41
+ ```
42
+
43
+ Register `JoltuiInterface` in your application's `app_extensions`, or inject it
44
+ with `joltui: Inject[JoltuiInterface]`. Set JolUI's `serverComponentsUrl` to
45
+ `/joltui/components` (including Patera's `URL_PREFIX`, if configured).
46
+ Override the endpoint with application configuration:
47
+ `JOLTUI_INTERFACE = {"COMPONENTS_PATH": "/components"}`.
48
+ The extension registers its own controller automatically; no application
49
+ controller or manual dispatch route is needed. `JoltuiConfig.COMPONENTS_PATH`
50
+ defaults to `/joltui/components`.
51
+
52
+ JolUI sends a JSON POST containing `tagName`, `groupName`, `routeParameters`,
53
+ `queryParameters`, and `attributes`. Omitted/null groups default to `default`;
54
+ omitted/null parameter mappings default to empty dictionaries. Tags are
55
+ trimmed and lowercased, while group names remain case-sensitive.
56
+
57
+ Handlers receive the original Patera `Request`, retaining cookies, sessions,
58
+ headers, request context, and response helpers. `joltui_request(req)` provides
59
+ validated frontend metadata as `tag_name`, `group_name`, `route_parameters`,
60
+ `query_parameters`, and `attributes`. HTTP URL parameters stay unchanged;
61
+ frontend route parameters are accessed through this metadata. The original
62
+ JSON is also available through `await req.json()`.
63
+
64
+ Always return a Patera `Response` with content type `text/html` (for example,
65
+ `req.res.html_body(markup)` or `await req.res.html("user.html", ...)`).
66
+ Plain strings and non-HTML responses are rejected. Sync handlers are supported too.
67
+ Malformed requests return 400; unknown groups/components return 404. Handler
68
+ exceptions follow Patera's normal exception pipeline, including 401/403.
69
+
70
+ Groups are app-scoped and support `Inject[T]` dependencies. Do not store
71
+ per-request data on group instances. Inherited component methods are exposed;
72
+ overriding a method without `@component` removes its registration. Duplicate
73
+ group names or component tags fail at initialization. Only decorated methods
74
+ in explicitly declared groups are callable. Interface annotations may be
75
+ inherited, overridden, or removed with a non-ComponentGroup annotation.
76
+
77
+ Group handlers are Patera controllers. Use the normal controller and endpoint
78
+ decorators, including group/endpoint `@login_required()` and `@role_required()`,
79
+ `@after_init`, `@before_request`, `@after_request`, `@consumes`, `@produces`,
80
+ `@cors`, `@no_cors`, `@max_content_length`, `@ignore`, and `@development`.
81
+ Authentication decorators require the application's usual authentication
82
+ middleware. For example:
83
+
84
+ ```python
85
+ from patera.auth import login_required, role_required
86
+ from patera.controller import after_request, produces
87
+ from patera import MediaType
88
+
89
+ @group("user-group")
90
+ @login_required()
91
+ class UserGroupHandler(JoltuiGroupHandler):
92
+ @after_request
93
+ def prevent_caching(self, res):
94
+ res.set_header("Cache-Control", "no-store")
95
+
96
+ @component("user-page")
97
+ @role_required("admin")
98
+ @produces(MediaType.TEXT_HTML)
99
+ async def user_page(self, req: Request):
100
+ return req.res.html_body("<h1>Users</h1>")
101
+ ```
102
+
103
+ Component selection runs before CORS, database, authentication, and application
104
+ middleware. They see the selected bound component as `req.route_handler` and
105
+ its group as `current_request.controller`. Normal controller hooks run once;
106
+ after-request hooks receive the handler's HTML `Response`.
107
+ Decorators such as `@produces` that operate on a response expect handlers to
108
+ return a `Response`, just as on ordinary endpoints. `@consumes` validates the
109
+ original JSON envelope, not just its `attributes` field.
110
+
111
+ All components share one HTTP POST URL: `@path`/HTTP method decorators do not
112
+ create additional component URLs, and WebSocket transport is not provided by
113
+ this endpoint. OpenAPI metadata can be attached, but individual RPC components
114
+ are not separate OpenAPI routes. OPTIONS preflights have no component body,
115
+ so they use the shared endpoint's global CORS policy; configure it to permit
116
+ the frontend. Component CORS overrides apply to actual POST responses.
117
+
118
+ The application's body limit protects JSON envelope parsing. Component
119
+ `@max_content_length` limits are additionally enforced after selection and
120
+ cannot bypass that initial envelope limit. Frontend metadata is client input,
121
+ not an authorization source. Escape values inserted into raw HTML, or use
122
+ your application's templates.
123
+
124
+
125
+ Handlers may declare an optional third argument annotated with any Pydantic
126
+ model. The extension validates the full JSON body into that model automatically;
127
+ `@consumes` is not required. Model validation failures return 422 before the
128
+ handler runs. Authentication and controller before-request hooks run first.
129
+
130
+ ```python
131
+ from pydantic import BaseModel, Field
132
+ from patera import Response
133
+
134
+ class UserRoute(BaseModel):
135
+ user_id: int
136
+
137
+ class UserPageBody(BaseModel):
138
+ route_parameters: UserRoute = Field(alias="routeParameters")
139
+ # tagName and groupName need not be declared in a custom model.
140
+ # Pydantic's default extra="ignore" ignores undeclared envelope fields.
141
+
142
+ @group("user-group")
143
+ class UserGroupHandler(JoltuiGroupHandler):
144
+ @component("user-page")
145
+ async def user_page(self, req: Request, body: UserPageBody) -> Response:
146
+ return req.res.html_body(f"<h1>User {body.route_parameters.user_id}</h1>")
147
+ ```
148
+
149
+ For example, the request body is:
150
+
151
+ ```json
152
+ {"tagName": "user-page", "groupName": "user-group", "routeParameters": {"user_id": "42"}}
153
+ ```
154
+
155
+ `body.route_parameters.user_id` is validated and converted to the integer 42.
156
+ The original `Request` and its JSON body remain available. Use `JoltuiRequest`
157
+ as the third argument if its standard fields suffice, or define your own model
158
+ with nested models and aliases as above. `JoltuiRequest.tag_name` defaults to
159
+ `None` and `group_name` to `"default"`, so neither field is required when
160
+ constructing the model. The actual RPC envelope still requires `tagName` to
161
+ select the handler; `groupName` may be omitted to select the default group.
162
+ Custom models receive the original body and follow their own Pydantic defaults,
163
+ coercion, extra-field policy, and validators.
@@ -0,0 +1,153 @@
1
+ # patera-joltui
2
+
3
+ Install `patera-joltui` (or `patera[joltui]`) and import from `patera.joltui`.
4
+
5
+ ```python
6
+ from html import escape
7
+ from patera import Request
8
+ from patera.injectable import Inject
9
+ from patera.joltui import (
10
+ ComponentGroup, JoltuiGroupHandler, PateraJoltui,
11
+ component, group, joltui_request,
12
+ )
13
+
14
+ @group("default")
15
+ class DefaultGroupHandler(JoltuiGroupHandler):
16
+ @component("home-page")
17
+ async def home_page(self, req: Request):
18
+ return req.res.html_body("<h1>Welcome</h1>")
19
+
20
+ @group("user-group")
21
+ class UserGroupHandler(JoltuiGroupHandler):
22
+ @component("user-page")
23
+ async def user_page(self, req: Request):
24
+ data = joltui_request(req)
25
+ user_id = escape(str(data.route_parameters["user_id"]))
26
+ return req.res.html_body(f"<h1>User {user_id}</h1>")
27
+
28
+ class JoltuiInterface(PateraJoltui):
29
+ default_group: ComponentGroup[DefaultGroupHandler]
30
+ user_group: ComponentGroup[UserGroupHandler]
31
+ ```
32
+
33
+ Register `JoltuiInterface` in your application's `app_extensions`, or inject it
34
+ with `joltui: Inject[JoltuiInterface]`. Set JolUI's `serverComponentsUrl` to
35
+ `/joltui/components` (including Patera's `URL_PREFIX`, if configured).
36
+ Override the endpoint with application configuration:
37
+ `JOLTUI_INTERFACE = {"COMPONENTS_PATH": "/components"}`.
38
+ The extension registers its own controller automatically; no application
39
+ controller or manual dispatch route is needed. `JoltuiConfig.COMPONENTS_PATH`
40
+ defaults to `/joltui/components`.
41
+
42
+ JolUI sends a JSON POST containing `tagName`, `groupName`, `routeParameters`,
43
+ `queryParameters`, and `attributes`. Omitted/null groups default to `default`;
44
+ omitted/null parameter mappings default to empty dictionaries. Tags are
45
+ trimmed and lowercased, while group names remain case-sensitive.
46
+
47
+ Handlers receive the original Patera `Request`, retaining cookies, sessions,
48
+ headers, request context, and response helpers. `joltui_request(req)` provides
49
+ validated frontend metadata as `tag_name`, `group_name`, `route_parameters`,
50
+ `query_parameters`, and `attributes`. HTTP URL parameters stay unchanged;
51
+ frontend route parameters are accessed through this metadata. The original
52
+ JSON is also available through `await req.json()`.
53
+
54
+ Always return a Patera `Response` with content type `text/html` (for example,
55
+ `req.res.html_body(markup)` or `await req.res.html("user.html", ...)`).
56
+ Plain strings and non-HTML responses are rejected. Sync handlers are supported too.
57
+ Malformed requests return 400; unknown groups/components return 404. Handler
58
+ exceptions follow Patera's normal exception pipeline, including 401/403.
59
+
60
+ Groups are app-scoped and support `Inject[T]` dependencies. Do not store
61
+ per-request data on group instances. Inherited component methods are exposed;
62
+ overriding a method without `@component` removes its registration. Duplicate
63
+ group names or component tags fail at initialization. Only decorated methods
64
+ in explicitly declared groups are callable. Interface annotations may be
65
+ inherited, overridden, or removed with a non-ComponentGroup annotation.
66
+
67
+ Group handlers are Patera controllers. Use the normal controller and endpoint
68
+ decorators, including group/endpoint `@login_required()` and `@role_required()`,
69
+ `@after_init`, `@before_request`, `@after_request`, `@consumes`, `@produces`,
70
+ `@cors`, `@no_cors`, `@max_content_length`, `@ignore`, and `@development`.
71
+ Authentication decorators require the application's usual authentication
72
+ middleware. For example:
73
+
74
+ ```python
75
+ from patera.auth import login_required, role_required
76
+ from patera.controller import after_request, produces
77
+ from patera import MediaType
78
+
79
+ @group("user-group")
80
+ @login_required()
81
+ class UserGroupHandler(JoltuiGroupHandler):
82
+ @after_request
83
+ def prevent_caching(self, res):
84
+ res.set_header("Cache-Control", "no-store")
85
+
86
+ @component("user-page")
87
+ @role_required("admin")
88
+ @produces(MediaType.TEXT_HTML)
89
+ async def user_page(self, req: Request):
90
+ return req.res.html_body("<h1>Users</h1>")
91
+ ```
92
+
93
+ Component selection runs before CORS, database, authentication, and application
94
+ middleware. They see the selected bound component as `req.route_handler` and
95
+ its group as `current_request.controller`. Normal controller hooks run once;
96
+ after-request hooks receive the handler's HTML `Response`.
97
+ Decorators such as `@produces` that operate on a response expect handlers to
98
+ return a `Response`, just as on ordinary endpoints. `@consumes` validates the
99
+ original JSON envelope, not just its `attributes` field.
100
+
101
+ All components share one HTTP POST URL: `@path`/HTTP method decorators do not
102
+ create additional component URLs, and WebSocket transport is not provided by
103
+ this endpoint. OpenAPI metadata can be attached, but individual RPC components
104
+ are not separate OpenAPI routes. OPTIONS preflights have no component body,
105
+ so they use the shared endpoint's global CORS policy; configure it to permit
106
+ the frontend. Component CORS overrides apply to actual POST responses.
107
+
108
+ The application's body limit protects JSON envelope parsing. Component
109
+ `@max_content_length` limits are additionally enforced after selection and
110
+ cannot bypass that initial envelope limit. Frontend metadata is client input,
111
+ not an authorization source. Escape values inserted into raw HTML, or use
112
+ your application's templates.
113
+
114
+
115
+ Handlers may declare an optional third argument annotated with any Pydantic
116
+ model. The extension validates the full JSON body into that model automatically;
117
+ `@consumes` is not required. Model validation failures return 422 before the
118
+ handler runs. Authentication and controller before-request hooks run first.
119
+
120
+ ```python
121
+ from pydantic import BaseModel, Field
122
+ from patera import Response
123
+
124
+ class UserRoute(BaseModel):
125
+ user_id: int
126
+
127
+ class UserPageBody(BaseModel):
128
+ route_parameters: UserRoute = Field(alias="routeParameters")
129
+ # tagName and groupName need not be declared in a custom model.
130
+ # Pydantic's default extra="ignore" ignores undeclared envelope fields.
131
+
132
+ @group("user-group")
133
+ class UserGroupHandler(JoltuiGroupHandler):
134
+ @component("user-page")
135
+ async def user_page(self, req: Request, body: UserPageBody) -> Response:
136
+ return req.res.html_body(f"<h1>User {body.route_parameters.user_id}</h1>")
137
+ ```
138
+
139
+ For example, the request body is:
140
+
141
+ ```json
142
+ {"tagName": "user-page", "groupName": "user-group", "routeParameters": {"user_id": "42"}}
143
+ ```
144
+
145
+ `body.route_parameters.user_id` is validated and converted to the integer 42.
146
+ The original `Request` and its JSON body remain available. Use `JoltuiRequest`
147
+ as the third argument if its standard fields suffice, or define your own model
148
+ with nested models and aliases as above. `JoltuiRequest.tag_name` defaults to
149
+ `None` and `group_name` to `"default"`, so neither field is required when
150
+ constructing the model. The actual RPC envelope still requires `tagName` to
151
+ select the handler; `groupName` may be omitted to select the default group.
152
+ Custom models receive the original body and follow their own Pydantic defaults,
153
+ coercion, extra-field policy, and validators.
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "patera-joltui"
3
+ version = "0.2.0"
4
+ description = "JolUI server component integration for Patera"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.13"
9
+ dependencies = ["patera>=0.122"]
10
+
11
+ [tool.uv.sources]
12
+ patera = { workspace = true }
13
+
14
+ [tool.uv.build-backend]
15
+ module-name = "patera.joltui"
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.10.9,<0.13.0"]
19
+ build-backend = "uv_build"
@@ -0,0 +1,23 @@
1
+ """JolUI server components for Patera."""
2
+
3
+ from .interface import (
4
+ ComponentGroup,
5
+ JoltuiConfig,
6
+ JoltuiGroupHandler,
7
+ JoltuiRequest,
8
+ PateraJoltui,
9
+ component,
10
+ group,
11
+ joltui_request,
12
+ )
13
+
14
+ __all__ = [
15
+ "ComponentGroup",
16
+ "JoltuiConfig",
17
+ "JoltuiGroupHandler",
18
+ "JoltuiRequest",
19
+ "PateraJoltui",
20
+ "component",
21
+ "group",
22
+ "joltui_request",
23
+ ]
@@ -0,0 +1,295 @@
1
+ """Dispatch JolUI's fetchHTML protocol through ordinary Patera requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import inspect
7
+ from functools import wraps
8
+ from types import MethodType
9
+ from typing import (
10
+ Annotated,
11
+ Any,
12
+ Callable,
13
+ TypeAlias,
14
+ TypeVar,
15
+ get_args,
16
+ get_origin,
17
+ get_type_hints,
18
+ )
19
+
20
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
21
+
22
+ from patera import BaseExtension, MediaType, Request, Response, run_sync_or_async
23
+ from patera.controller.utilities import _build_model, _unwrap_annotated
24
+ from patera.controller import Controller, path, post
25
+ from patera.exceptions.http_exceptions import (
26
+ BaseHttpException,
27
+ InvalidRequestBody,
28
+ RequestBodyTooLarge,
29
+ )
30
+ from patera.ctx import get_request_context
31
+ from patera.middleware import MiddlewareBase
32
+ from patera.injectable import _own_annotations, _resolve_annotation
33
+
34
+ T = TypeVar("T", bound="JoltuiGroupHandler")
35
+ F = TypeVar("F", bound=Callable[..., Any])
36
+
37
+
38
+ class _ComponentGroup:
39
+ pass
40
+
41
+
42
+ ComponentGroup: TypeAlias = Annotated[T, _ComponentGroup()]
43
+
44
+
45
+ def _name(value: str, *, tag: bool = False) -> str:
46
+ if not isinstance(value, str) or not value.strip():
47
+ raise ValueError("Component and group names must be non-empty strings")
48
+ return value.strip().lower() if tag else value.strip()
49
+
50
+
51
+ def group(name: str) -> Callable[[type[T]], type[T]]:
52
+ """Name a group; only groups declared on an interface are exposed."""
53
+ name = _name(name)
54
+
55
+ def decorate(cls: type[T]) -> type[T]:
56
+ if not issubclass(cls, JoltuiGroupHandler):
57
+ raise TypeError("@group requires a JoltuiGroupHandler subclass")
58
+ cls.__joltui_group__ = name
59
+ return cls
60
+
61
+ return decorate
62
+
63
+
64
+ def component(tag: str) -> Callable[[F], F]:
65
+ """Expose a handler under a case-insensitive component tag."""
66
+ tag = _name(tag, tag=True)
67
+
68
+ def decorate(func: F) -> F:
69
+ setattr(func, "__joltui_component__", tag)
70
+ return func
71
+
72
+ return decorate
73
+
74
+
75
+ class JoltuiGroupHandler(Controller):
76
+ """App-scoped controller with normal injection and lifecycle decorators."""
77
+
78
+ __joltui_group__: str
79
+
80
+ async def __call__(self, method: Callable, req: Request) -> Response:
81
+ @wraps(method)
82
+ async def render(request: Request, **kwargs: Any) -> Response:
83
+ body_parameter = _body_parameter(method)
84
+ if body_parameter is not None:
85
+ name, model = body_parameter
86
+ kwargs[name] = _build_model(model, await request.json())
87
+ result = await run_sync_or_async(method, request, **kwargs)
88
+ _require_html_response(result)
89
+ return result
90
+
91
+ result = await super().__call__(render, req)
92
+ _require_html_response(result)
93
+ return result
94
+
95
+
96
+ def _require_html_response(result: Any) -> None:
97
+ if not isinstance(result, Response):
98
+ raise TypeError("JolUI component handlers must return an HTML Response")
99
+ content_type = result.content_type or result.media_type or ""
100
+ if content_type.split(";", 1)[0].strip().lower() != MediaType.TEXT_HTML.value:
101
+ raise TypeError("JolUI component responses must use text/html")
102
+
103
+
104
+ def _body_parameter(method: Callable) -> tuple[str, type[BaseModel]] | None:
105
+ # Bound method parameters are (req, body); wraps preserves the original
106
+ # signature and annotations through ordinary endpoint decorators.
107
+ parameters = list(inspect.signature(method).parameters.values())
108
+ if len(parameters) == 1:
109
+ return None
110
+ if len(parameters) != 2 or parameters[1].kind not in (
111
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
112
+ inspect.Parameter.KEYWORD_ONLY,
113
+ ):
114
+ raise TypeError("Component handlers require (self, req[, body: BaseModel])")
115
+ parameter = parameters[1]
116
+ model = _unwrap_annotated(
117
+ get_type_hints(method, include_extras=True).get(parameter.name)
118
+ )
119
+ if not isinstance(model, type) or not issubclass(model, BaseModel):
120
+ raise TypeError("The third component handler argument must be a Pydantic model")
121
+ return parameter.name, model
122
+
123
+
124
+ class _JoltuiDispatchMiddleware(MiddlewareBase):
125
+ # Resolve before CORS (-101), database sessions (-100), and auth (-99).
126
+ _order_ = -102
127
+ _middleware = True
128
+
129
+ async def middleware(self, req: Request) -> Response:
130
+ extension = getattr(req.route_handler, "__joltui_interface__", None)
131
+ if extension is not None and req.method == "POST":
132
+ await extension.resolve(req)
133
+ return await self.next(req)
134
+
135
+
136
+ class JoltuiConfig(BaseModel):
137
+ COMPONENTS_PATH: str = "/joltui/components"
138
+
139
+ @field_validator("COMPONENTS_PATH")
140
+ @classmethod
141
+ def validate_path(cls, value: str) -> str:
142
+ if not value.startswith("/") or any(c in value for c in "?#{<"):
143
+ raise ValueError("COMPONENTS_PATH must be an absolute, literal URL path")
144
+ return value
145
+
146
+
147
+ class JoltuiRequest(BaseModel):
148
+ """Frontend metadata, separate from the HTTP request's URL parameters."""
149
+
150
+ model_config = ConfigDict(strict=True, populate_by_name=True)
151
+ tag_name: str | None = Field(default=None, alias="tagName", min_length=1)
152
+ group_name: str = Field(default="default", alias="groupName")
153
+ route_parameters: dict[str, str | int | float] = Field(
154
+ default_factory=dict, alias="routeParameters"
155
+ )
156
+ query_parameters: dict[str, str] = Field(
157
+ default_factory=dict, alias="queryParameters"
158
+ )
159
+ attributes: dict[str, Any] = Field(default_factory=dict)
160
+
161
+ @field_validator("tag_name")
162
+ @classmethod
163
+ def normalize_tag(cls, value: str | None) -> str | None:
164
+ return _name(value, tag=True) if value is not None else None
165
+
166
+ @field_validator("group_name", mode="before")
167
+ @classmethod
168
+ def normalize_group(cls, value: Any) -> Any:
169
+ return (
170
+ (value.strip() or "default")
171
+ if isinstance(value, str)
172
+ else ("default" if value is None else value)
173
+ )
174
+
175
+ @field_validator(
176
+ "route_parameters", "query_parameters", "attributes", mode="before"
177
+ )
178
+ @classmethod
179
+ def normalize_mapping(cls, value: Any) -> Any:
180
+ return {} if value is None else value
181
+
182
+
183
+ def joltui_request(req: Request) -> JoltuiRequest:
184
+ """Return metadata during a component dispatch, without replacing Request."""
185
+ value = req.scope.get("patera.joltui")
186
+ if not isinstance(value, JoltuiRequest):
187
+ raise RuntimeError("This request is not a JolUI component request")
188
+ return value
189
+
190
+
191
+ class PateraJoltui(BaseExtension[Any, JoltuiConfig]):
192
+ """Mount a POST endpoint for declaratively registered component groups."""
193
+
194
+ def init(self) -> None:
195
+ declarations: dict[str, type[JoltuiGroupHandler]] = {}
196
+ for cls in reversed(type(self).__mro__):
197
+ for name, raw in _own_annotations(cls).items():
198
+ declarations.pop(name, None)
199
+ if isinstance(raw, str):
200
+ if not re.search(r"\b(?:ComponentGroup|Annotated)\s*\[", raw):
201
+ continue
202
+ raw = _resolve_annotation(cls, name, raw)
203
+ if get_origin(raw) is not Annotated:
204
+ continue
205
+ target, *metadata = get_args(raw)
206
+ if not any(isinstance(m, _ComponentGroup) for m in metadata):
207
+ continue
208
+ if not isinstance(target, type) or not issubclass(
209
+ target, JoltuiGroupHandler
210
+ ):
211
+ raise TypeError(
212
+ f"{name}: ComponentGroup requires a JoltuiGroupHandler"
213
+ )
214
+ declarations[name] = target
215
+
216
+ self._handlers: dict[tuple[str, str], MethodType] = {}
217
+ groups: set[str] = set()
218
+ for name, target in declarations.items():
219
+ group_name = getattr(target, "__joltui_group__", None)
220
+ if group_name is None:
221
+ raise TypeError(f"{target.__name__} needs a @group decorator")
222
+ if group_name in groups:
223
+ raise ValueError(f"Duplicate JolUI group: {group_name}")
224
+ groups.add(group_name)
225
+ if self._excluded(target):
226
+ continue
227
+ handler = self._resolve_dependency(target)
228
+ setattr(self, name, handler)
229
+ members: dict[str, Any] = {}
230
+ for base in reversed(target.__mro__):
231
+ members.update(vars(base))
232
+ for method_name, method in members.items():
233
+ tag = getattr(method, "__joltui_component__", None)
234
+ if tag is None:
235
+ continue
236
+ if self._excluded(method):
237
+ continue
238
+ key = (group_name, tag)
239
+ if key in self._handlers:
240
+ raise ValueError(f"Duplicate JolUI component: {group_name}/{tag}")
241
+ self._handlers[key] = getattr(handler, method_name)
242
+ _body_parameter(self._handlers[key])
243
+
244
+ extension = self
245
+
246
+ @path(self.configs.COMPONENTS_PATH, open_api_spec=False)
247
+ class JoltuiComponents(Controller):
248
+ @post("")
249
+ async def render_component(self, req: Request) -> Response:
250
+ return await extension.dispatch(req)
251
+
252
+ JoltuiComponents.__name__ = f"{type(self).__name__}Components"
253
+ setattr(JoltuiComponents.render_component, "__joltui_interface__", self)
254
+ self.app.register_controller(JoltuiComponents)
255
+ if _JoltuiDispatchMiddleware not in self.app._middleware_classes.values():
256
+ self.app.register_middleware(_JoltuiDispatchMiddleware)
257
+
258
+ def _excluded(self, target: Any) -> bool:
259
+ return bool(
260
+ getattr(target, "_ignore", False)
261
+ or (
262
+ getattr(target, "_development", False)
263
+ and not self.app.get_conf("DEBUG", False)
264
+ )
265
+ )
266
+
267
+ async def resolve(self, req: Request) -> None:
268
+ """Select the effective endpoint before ordinary middleware inspects it."""
269
+ try:
270
+ payload = JoltuiRequest.model_validate(await req.json())
271
+ except ValidationError as exc:
272
+ raise InvalidRequestBody("Invalid JolUI component request") from exc
273
+ if payload.tag_name is None:
274
+ raise InvalidRequestBody("tagName is required for component dispatch")
275
+ handler = self._handlers.get((payload.group_name, payload.tag_name))
276
+ if handler is None:
277
+ raise BaseHttpException("Unknown JolUI component", status_code=404)
278
+ req.scope["patera.joltui"] = payload
279
+ req._route_handler = handler
280
+ get_request_context().controller = handler.__self__
281
+ # The shared endpoint's body limit protects envelope parsing. A smaller
282
+ # component limit is checked once the envelope identifies the handler.
283
+ limit = getattr(handler, "_max_content_length", req.max_content_length)
284
+ req.max_content_length = limit
285
+ if limit is not None and len(await req.body()) > limit:
286
+ raise RequestBodyTooLarge(limit)
287
+
288
+ async def dispatch(self, req: Request) -> Response:
289
+ """Fallback for direct invocation of the registered controller."""
290
+ await self.resolve(req)
291
+ handler = req.route_handler
292
+ assert isinstance(handler, MethodType)
293
+ controller = handler.__self__
294
+ assert isinstance(controller, JoltuiGroupHandler)
295
+ return await controller(handler, req)
File without changes