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.
- patera_joltui-0.2.0/LICENSE +21 -0
- patera_joltui-0.2.0/PKG-INFO +163 -0
- patera_joltui-0.2.0/README.md +153 -0
- patera_joltui-0.2.0/pyproject.toml +19 -0
- patera_joltui-0.2.0/src/patera/joltui/__init__.py +23 -0
- patera_joltui-0.2.0/src/patera/joltui/interface.py +295 -0
- patera_joltui-0.2.0/src/patera/joltui/py.typed +0 -0
|
@@ -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
|