nyxa-forms 0.0.1__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.
- nyxa_forms/__init__.py +23 -0
- nyxa_forms/commands.py +71 -0
- nyxa_forms/handler.py +35 -0
- nyxa_forms/helpers.py +134 -0
- nyxa_forms/kind.py +58 -0
- nyxa_forms/provider.py +109 -0
- nyxa_forms/request.py +135 -0
- nyxa_forms/stubs/config_forms.stub +3 -0
- nyxa_forms/validator.py +2036 -0
- nyxa_forms-0.0.1.dist-info/METADATA +146 -0
- nyxa_forms-0.0.1.dist-info/RECORD +15 -0
- nyxa_forms-0.0.1.dist-info/WHEEL +5 -0
- nyxa_forms-0.0.1.dist-info/entry_points.txt +2 -0
- nyxa_forms-0.0.1.dist-info/licenses/LICENSE +21 -0
- nyxa_forms-0.0.1.dist-info/top_level.txt +1 -0
nyxa_forms/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""nyxa-forms — web validation: FormRequest, rule strings, error bags, old input."""
|
|
2
|
+
|
|
3
|
+
from nyxa_forms.provider import FormsProvider
|
|
4
|
+
from nyxa_forms.request import FormRequest
|
|
5
|
+
from nyxa_forms.validator import (
|
|
6
|
+
Enum,
|
|
7
|
+
Password,
|
|
8
|
+
PasswordRule,
|
|
9
|
+
ValidationException,
|
|
10
|
+
Validator,
|
|
11
|
+
rule,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Enum",
|
|
16
|
+
"FormRequest",
|
|
17
|
+
"FormsProvider",
|
|
18
|
+
"Password",
|
|
19
|
+
"PasswordRule",
|
|
20
|
+
"ValidationException",
|
|
21
|
+
"Validator",
|
|
22
|
+
"rule",
|
|
23
|
+
]
|
nyxa_forms/commands.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""``make:request`` — scaffold a FormRequest under ``app/requests/``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from nyxa.command import Command
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MakeRequestCommand(Command):
|
|
11
|
+
"""``nyxa make:request`` — generate a FormRequest class."""
|
|
12
|
+
|
|
13
|
+
signature = (
|
|
14
|
+
"make:request {name : resource/module name (post → app/requests/post.py)} "
|
|
15
|
+
"{--fields= : field specs name:type[:rules…],comma-separated} "
|
|
16
|
+
"{--update : emit only the Update class} "
|
|
17
|
+
"{--describe= : natural-language intent — AI/fallback fills rules} "
|
|
18
|
+
"{--force : overwrite existing}"
|
|
19
|
+
)
|
|
20
|
+
help = "Generate a FormRequest under app/requests/ (--fields for rule bodies)."
|
|
21
|
+
needs_app = False
|
|
22
|
+
|
|
23
|
+
def handle(
|
|
24
|
+
self,
|
|
25
|
+
name: str,
|
|
26
|
+
fields: str | None = None,
|
|
27
|
+
update: bool = False,
|
|
28
|
+
describe: str | None = None,
|
|
29
|
+
force: bool = False,
|
|
30
|
+
) -> int:
|
|
31
|
+
"""Write ``app/requests/<name>.py`` (optionally field-driven rules).
|
|
32
|
+
|
|
33
|
+
``name`` is the resource/module name: ``post`` writes
|
|
34
|
+
``app/requests/post.py`` holding the ``Store`` and ``Update``
|
|
35
|
+
FormRequest classes.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
name: Resource/module name (must be a valid identifier).
|
|
39
|
+
fields: Optional ``name:type[:rules…]`` specs; when set, rules are
|
|
40
|
+
generated via the shared field grammar for both classes.
|
|
41
|
+
update: Emit only the ``Update`` class.
|
|
42
|
+
describe: Natural-language AI fill of the request rules.
|
|
43
|
+
force: Overwrite an existing file.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
``0`` on success, ``1`` on invalid name or refusal to overwrite.
|
|
47
|
+
"""
|
|
48
|
+
import re
|
|
49
|
+
|
|
50
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", name):
|
|
51
|
+
self.error(f"invalid resource name {name!r}")
|
|
52
|
+
return 1
|
|
53
|
+
from nyxa.cli.make import make_form_request
|
|
54
|
+
|
|
55
|
+
make_form_request(
|
|
56
|
+
name,
|
|
57
|
+
fields,
|
|
58
|
+
for_update=update,
|
|
59
|
+
io=self.io,
|
|
60
|
+
force=force,
|
|
61
|
+
)
|
|
62
|
+
if describe:
|
|
63
|
+
from nyxa.cli.ai_scaffold import fill
|
|
64
|
+
|
|
65
|
+
root = Path.cwd()
|
|
66
|
+
snake = "".join(f"_{c.lower()}" if c.isupper() else c for c in name).lstrip("_")
|
|
67
|
+
target = root / "app" / "requests" / f"{snake}.py"
|
|
68
|
+
if target.is_file():
|
|
69
|
+
body = fill(describe, "form request", name, target.read_text())
|
|
70
|
+
target.write_text(body)
|
|
71
|
+
return 0
|
nyxa_forms/handler.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""``FormsExceptionHandler`` — HTML redirect-back vs JSON 422 for validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, override
|
|
6
|
+
|
|
7
|
+
from nyxa.exceptions import ExceptionHandler
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from nyxa.http.context import HttpContext
|
|
11
|
+
from nyxa.http.response import Response
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FormsExceptionHandler(ExceptionHandler):
|
|
15
|
+
"""Wraps the app's handler — ``ValidationException`` becomes a
|
|
16
|
+
redirect-back + flashed errors/old-input for HTML requests, 422
|
|
17
|
+
JSON otherwise. Everything else delegates to the previous handler.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, inner: ExceptionHandler) -> None:
|
|
21
|
+
"""Wrap ``inner`` — the app's previous handler."""
|
|
22
|
+
self.inner = inner
|
|
23
|
+
|
|
24
|
+
@override
|
|
25
|
+
async def handle(self, error: Exception, ctx: HttpContext) -> Response:
|
|
26
|
+
"""Redirect-back with flash for HTML validation failures."""
|
|
27
|
+
from nyxa.http.response import Response
|
|
28
|
+
from nyxa_forms.helpers import flash_old, with_errors
|
|
29
|
+
from nyxa_forms.validator import ValidationException
|
|
30
|
+
|
|
31
|
+
if isinstance(error, ValidationException) and not ctx.request.wants_json():
|
|
32
|
+
with_errors(ctx, error.bag)
|
|
33
|
+
flash_old(ctx, error.data)
|
|
34
|
+
return Response.redirect(ctx.request.header("referer") or "/", status=303)
|
|
35
|
+
return await self.inner.handle(error, ctx)
|
nyxa_forms/helpers.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Flash/old-input + redirect helpers layered on ``nyxa-session``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import TYPE_CHECKING, Protocol, TypeGuard, overload, runtime_checkable
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from nyxa.http.context import HttpContext
|
|
10
|
+
from nyxa.http.response import Response
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class _FlashSession(Protocol):
|
|
15
|
+
"""Session bag surface used for one-request form flashes."""
|
|
16
|
+
|
|
17
|
+
def get(self, key: str, default: object = None) -> object:
|
|
18
|
+
"""Read one session value."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def flash(self, key: str, value: object) -> None:
|
|
22
|
+
"""Persist one value for the next request."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _session(ctx: HttpContext) -> _FlashSession | None:
|
|
27
|
+
"""Narrow the optional session-store object held in request state.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
ctx: Request context whose session middleware may have populated state.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
A compatible flash session, or ``None`` when unavailable.
|
|
34
|
+
"""
|
|
35
|
+
value: object = ctx.state.get("session")
|
|
36
|
+
return value if isinstance(value, _FlashSession) else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _object_dict(value: object) -> TypeGuard[dict[str, object]]:
|
|
40
|
+
"""Validate a dynamic form bag as a string-keyed mapping."""
|
|
41
|
+
return isinstance(value, dict) and all(isinstance(key, str) for key in value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _object_list(value: object) -> TypeGuard[list[object]]:
|
|
45
|
+
"""Narrow one field's dynamic error messages to a list."""
|
|
46
|
+
return isinstance(value, list)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def old(ctx: HttpContext, key: str, default: object = "") -> object:
|
|
50
|
+
"""Return a prior request's flashed value for one field.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
ctx: Current request context.
|
|
54
|
+
key: Field name whose old input is requested.
|
|
55
|
+
default: Value returned when the field was not flashed.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Flashed field value or ``default``.
|
|
59
|
+
"""
|
|
60
|
+
session = _session(ctx)
|
|
61
|
+
if session is None:
|
|
62
|
+
return default
|
|
63
|
+
bag: object = session.get("_old", {})
|
|
64
|
+
return bag.get(key, default) if _object_dict(bag) else default
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@overload
|
|
68
|
+
def errors(ctx: HttpContext, key: None = None) -> dict[str, object]: ...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@overload
|
|
72
|
+
def errors(ctx: HttpContext, key: str) -> list[object]: ...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def errors(ctx: HttpContext, key: str | None = None) -> dict[str, object] | list[object]:
|
|
76
|
+
"""Return the flashed validation bag or messages for one field.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
ctx: Current request context.
|
|
80
|
+
key: Optional field whose validation messages are requested.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A copied error bag or a field's message list; malformed state yields empty data.
|
|
84
|
+
"""
|
|
85
|
+
session = _session(ctx)
|
|
86
|
+
if session is None:
|
|
87
|
+
return {} if key is None else []
|
|
88
|
+
raw_bag: object = session.get("errors", {})
|
|
89
|
+
if not _object_dict(raw_bag):
|
|
90
|
+
return {} if key is None else []
|
|
91
|
+
if key is None:
|
|
92
|
+
return raw_bag
|
|
93
|
+
raw_messages: object = raw_bag.get(key, [])
|
|
94
|
+
return raw_messages if _object_list(raw_messages) else []
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def with_errors(ctx: HttpContext, bag: Mapping[str, object]) -> None:
|
|
98
|
+
"""Flash a validation error bag for the next request.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
ctx: Current request context.
|
|
102
|
+
bag: Field names mapped to their validation messages.
|
|
103
|
+
"""
|
|
104
|
+
session = _session(ctx)
|
|
105
|
+
if session is not None:
|
|
106
|
+
session.flash("errors", dict(bag))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def flash_old(ctx: HttpContext, data: Mapping[str, object]) -> None:
|
|
110
|
+
"""Flash request input for ``old()`` repopulation.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
ctx: Current request context.
|
|
114
|
+
data: Input fields to make available to the next request.
|
|
115
|
+
"""
|
|
116
|
+
session = _session(ctx)
|
|
117
|
+
if session is not None:
|
|
118
|
+
session.flash("_old", dict(data))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def back(ctx: HttpContext, *, fallback: str = "/") -> Response:
|
|
122
|
+
"""Redirect to the referer, using ``fallback`` when none is present.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
ctx: Current request context.
|
|
126
|
+
fallback: Relative URL used when the request has no referer.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Redirect response to the request referer or fallback URL.
|
|
130
|
+
"""
|
|
131
|
+
from nyxa.http.response import Response
|
|
132
|
+
|
|
133
|
+
url = ctx.request.header("referer") or fallback
|
|
134
|
+
return Response.redirect(url)
|
nyxa_forms/kind.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""``FormRequest`` contract kind — route injection via contract_kinds()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from nyxa_forms.request import FormRequest
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from nyxa.contracts import Decoder, Encoder
|
|
11
|
+
from nyxa.http.request import Request
|
|
12
|
+
from nyxa.types import JSONValue
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FormRequestKind:
|
|
16
|
+
"""Contract kind resolving ``FormRequest`` subclasses on routes."""
|
|
17
|
+
|
|
18
|
+
name = "form_request"
|
|
19
|
+
|
|
20
|
+
def matches(self, model: object) -> bool:
|
|
21
|
+
"""Whether ``model`` is a ``FormRequest`` subclass."""
|
|
22
|
+
return isinstance(model, type) and issubclass(model, FormRequest)
|
|
23
|
+
|
|
24
|
+
def request_decoder(self, model: type) -> Decoder:
|
|
25
|
+
"""Build a decoder returning a validated ``FormRequest``."""
|
|
26
|
+
assert issubclass(model, FormRequest)
|
|
27
|
+
|
|
28
|
+
async def decode(request: Request) -> FormRequest:
|
|
29
|
+
"""Validate the request payload into a FormRequest.
|
|
30
|
+
|
|
31
|
+
Awaitable by design — ``serve._dispatch`` awaits decoders that
|
|
32
|
+
return coroutines, which is how db-aware rules work.
|
|
33
|
+
"""
|
|
34
|
+
from nyxa.http.context import current_ctx
|
|
35
|
+
|
|
36
|
+
ctx = current_ctx()
|
|
37
|
+
payload = request._payload()
|
|
38
|
+
data = dict(payload) if isinstance(payload, dict) else {}
|
|
39
|
+
instance = model()
|
|
40
|
+
await instance.validate_against(ctx, data)
|
|
41
|
+
return instance
|
|
42
|
+
|
|
43
|
+
return decode
|
|
44
|
+
|
|
45
|
+
def encoder(self, model: type) -> Encoder:
|
|
46
|
+
"""Encode (unused — FormRequests are input contracts)."""
|
|
47
|
+
|
|
48
|
+
def encode(value: object) -> bytes:
|
|
49
|
+
"""Serialize a value — FormRequests are input-only contracts."""
|
|
50
|
+
import orjson
|
|
51
|
+
|
|
52
|
+
return orjson.dumps(value)
|
|
53
|
+
|
|
54
|
+
return encode
|
|
55
|
+
|
|
56
|
+
def schema(self, model: type) -> dict[str, JSONValue]:
|
|
57
|
+
"""No OpenAPI schema for rule-string contracts."""
|
|
58
|
+
return {}
|
nyxa_forms/provider.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""FormsProvider — FormRequest contract kind, ``ctx.validate_web``, helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Coroutine, Iterable, Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, override
|
|
8
|
+
|
|
9
|
+
from nyxa.providers import ServiceProvider
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from nyxa.contracts import ContractKind
|
|
13
|
+
from nyxa.http.context import HttpContext
|
|
14
|
+
|
|
15
|
+
_STUBS = Path(__file__).parent / "stubs"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FormsProvider(ServiceProvider):
|
|
19
|
+
"""Registers the forms surface: validator macros + FormRequest kind."""
|
|
20
|
+
|
|
21
|
+
name = "forms"
|
|
22
|
+
|
|
23
|
+
@override
|
|
24
|
+
def contract_kinds(self) -> Iterable[ContractKind]:
|
|
25
|
+
"""The ``FormRequest`` model family for route injection."""
|
|
26
|
+
from nyxa_forms.kind import FormRequestKind
|
|
27
|
+
|
|
28
|
+
return [FormRequestKind()]
|
|
29
|
+
|
|
30
|
+
@override
|
|
31
|
+
def boot(self) -> None:
|
|
32
|
+
"""Wrap the exception handler — HTML validation → redirect+flash."""
|
|
33
|
+
from nyxa_forms.handler import FormsExceptionHandler
|
|
34
|
+
|
|
35
|
+
self.app.use_exception_handler(FormsExceptionHandler(self.app.exception_handler))
|
|
36
|
+
|
|
37
|
+
@override
|
|
38
|
+
def macros(self) -> Mapping[type, Mapping[str, object]]:
|
|
39
|
+
"""``ctx.validate_web``, ``ctx.old``, ``ctx.errors``, ``ctx.back``."""
|
|
40
|
+
from nyxa.http.context import HttpContext
|
|
41
|
+
from nyxa_forms.helpers import back, errors, old
|
|
42
|
+
|
|
43
|
+
def _validate_web(
|
|
44
|
+
ctx: HttpContext,
|
|
45
|
+
) -> Callable[[dict[str, str]], Coroutine[object, object, dict[str, object]]]:
|
|
46
|
+
"""``ctx.validate_web`` — the request validator factory."""
|
|
47
|
+
|
|
48
|
+
async def validate(rules: dict[str, str]) -> dict[str, object]:
|
|
49
|
+
"""Validate request input against ``rules`` — raises on fail."""
|
|
50
|
+
from nyxa_forms.validator import Validator
|
|
51
|
+
|
|
52
|
+
payload = ctx.request._payload()
|
|
53
|
+
data = dict(payload) if isinstance(payload, dict) else {}
|
|
54
|
+
v = Validator(data, rules, app=ctx.app, ctx=ctx)
|
|
55
|
+
return await v.validate()
|
|
56
|
+
|
|
57
|
+
return validate
|
|
58
|
+
|
|
59
|
+
def _old(ctx: HttpContext) -> Callable[[str], object]:
|
|
60
|
+
"""``ctx.old`` — flashed-input getter factory."""
|
|
61
|
+
|
|
62
|
+
def get(key: str, default: object = "") -> object:
|
|
63
|
+
"""``ctx.old("email")`` — flashed input or ``default``."""
|
|
64
|
+
return old(ctx, key, default)
|
|
65
|
+
|
|
66
|
+
return get
|
|
67
|
+
|
|
68
|
+
def _errors(ctx: HttpContext) -> Callable[[], object]:
|
|
69
|
+
"""``ctx.errors`` — flashed error-bag getter factory."""
|
|
70
|
+
|
|
71
|
+
def get(key: str | None = None) -> object:
|
|
72
|
+
"""``ctx.errors()`` bag or ``ctx.errors("email")`` messages."""
|
|
73
|
+
return errors(ctx, key)
|
|
74
|
+
|
|
75
|
+
return get
|
|
76
|
+
|
|
77
|
+
def _back(ctx: HttpContext) -> Callable[[], object]:
|
|
78
|
+
"""``ctx.back`` — redirect-to-referer factory."""
|
|
79
|
+
|
|
80
|
+
def go(*, fallback: str = "/") -> object:
|
|
81
|
+
"""``ctx.back()`` — redirect to the referer."""
|
|
82
|
+
return back(ctx, fallback=fallback)
|
|
83
|
+
|
|
84
|
+
return go
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
HttpContext: {
|
|
88
|
+
"validate_web": property(_validate_web),
|
|
89
|
+
"old": property(_old),
|
|
90
|
+
"errors": property(_errors),
|
|
91
|
+
"back": property(_back),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@override
|
|
96
|
+
def commands(self) -> Iterable[type]:
|
|
97
|
+
"""``make:request``."""
|
|
98
|
+
from nyxa_forms.commands import MakeRequestCommand
|
|
99
|
+
|
|
100
|
+
return [MakeRequestCommand]
|
|
101
|
+
|
|
102
|
+
@override
|
|
103
|
+
def configure(self) -> None:
|
|
104
|
+
"""``nyxa add nyxa-forms`` — write ``config/forms.py`` + a request stub."""
|
|
105
|
+
target = self.app.root / "config" / "forms.py"
|
|
106
|
+
if not target.exists():
|
|
107
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
target.write_text((_STUBS / "config_forms.stub").read_text())
|
|
109
|
+
print(f"nyxa-forms configured — config at {target}")
|
nyxa_forms/request.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""``FormRequest`` — authorize + rules + validated() for web forms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Awaitable, Mapping
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from nyxa_forms.validator import RuleSpec, Validator
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from nyxa.http.context import HttpContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FormRequest:
|
|
16
|
+
"""Base class for injectable, self-validating web requests.
|
|
17
|
+
|
|
18
|
+
::
|
|
19
|
+
|
|
20
|
+
class StorePost(FormRequest):
|
|
21
|
+
def rules(self) -> dict[str, str]:
|
|
22
|
+
return {"title": "required|max:255", "body": "required"}
|
|
23
|
+
|
|
24
|
+
def authorize(self, ctx) -> bool:
|
|
25
|
+
return ctx.user is not None
|
|
26
|
+
|
|
27
|
+
@app.router.post("/posts")
|
|
28
|
+
def store(ctx, request: StorePost):
|
|
29
|
+
Post.create(request.validated())
|
|
30
|
+
|
|
31
|
+
A handler param typed to a ``FormRequest`` subclass is injected by
|
|
32
|
+
the contract kind registered by ``FormsProvider``.
|
|
33
|
+
|
|
34
|
+
Pipeline: ``prepare_for_validation`` → ``authorize`` → build
|
|
35
|
+
validator (``with_validator`` hook) → rules + ``sometimes`` →
|
|
36
|
+
``after`` hook → ``validated``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
_validated: dict[str, object]
|
|
40
|
+
_errors: dict[str, list[str]]
|
|
41
|
+
_data: dict[str, object]
|
|
42
|
+
ctx: HttpContext | None = None
|
|
43
|
+
|
|
44
|
+
#: halt all rule evaluation at the first failure when True
|
|
45
|
+
stop_on_first_failure = False
|
|
46
|
+
|
|
47
|
+
def authorize(self, ctx: HttpContext | None) -> bool:
|
|
48
|
+
"""Whether the request is allowed — default: always."""
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
def rules(self) -> Mapping[str, RuleSpec]:
|
|
52
|
+
"""Validation rules for this request."""
|
|
53
|
+
return {}
|
|
54
|
+
|
|
55
|
+
def messages(self) -> Mapping[str, str]:
|
|
56
|
+
"""Custom ``field.rule`` messages."""
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
def attributes(self) -> Mapping[str, str]:
|
|
60
|
+
"""Field name → friendly name used in generated messages."""
|
|
61
|
+
return {}
|
|
62
|
+
|
|
63
|
+
def prepare_for_validation(
|
|
64
|
+
self, data: dict[str, object]
|
|
65
|
+
) -> dict[str, object] | Awaitable[dict[str, object] | None] | None:
|
|
66
|
+
"""Mutate/replace input before rules run (sync or async).
|
|
67
|
+
|
|
68
|
+
Return the new payload, or ``None`` to keep in-place edits —
|
|
69
|
+
for trimming, merging route params, or normalizing input.
|
|
70
|
+
"""
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def with_validator(self, validator: Validator) -> Awaitable[None] | None:
|
|
74
|
+
"""Configure the validator before rules run (e.g. ``sometimes()``)."""
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def after(self, validator: Validator) -> Awaitable[None] | None:
|
|
78
|
+
"""Post-validation hook — ``validator.add_error`` fails the request."""
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def validated(self, *keys: str) -> dict[str, object]:
|
|
82
|
+
"""Validated input — optionally filtered to top-level ``keys``."""
|
|
83
|
+
if keys:
|
|
84
|
+
return {k: self._validated[k] for k in keys if k in self._validated}
|
|
85
|
+
return dict(self._validated)
|
|
86
|
+
|
|
87
|
+
def errors(self) -> dict[str, list[str]]:
|
|
88
|
+
"""The error bag after a failed validation (empty on success)."""
|
|
89
|
+
return self._errors
|
|
90
|
+
|
|
91
|
+
def input(self, key: str | None = None, default: object = None) -> object:
|
|
92
|
+
"""Raw (prepared) request input — all, or one key."""
|
|
93
|
+
return self._data if key is None else self._data.get(key, default)
|
|
94
|
+
|
|
95
|
+
async def _maybe_await[T](self, result: T | Awaitable[T]) -> T:
|
|
96
|
+
"""Await ``result`` when it is awaitable, else return it."""
|
|
97
|
+
if inspect.isawaitable(result):
|
|
98
|
+
return await result
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
async def validate_against(self, ctx: HttpContext | None, data: dict[str, object]) -> None:
|
|
102
|
+
"""Run the prepare → authorize → rules → after pipeline; raises."""
|
|
103
|
+
from nyxa.http.exceptions import ForbiddenException
|
|
104
|
+
from nyxa_forms.validator import ValidationException
|
|
105
|
+
|
|
106
|
+
self.ctx = ctx
|
|
107
|
+
prepared = await self._maybe_await(self.prepare_for_validation(data))
|
|
108
|
+
if prepared is not None:
|
|
109
|
+
data = prepared
|
|
110
|
+
self._data = data
|
|
111
|
+
if not self.authorize(ctx):
|
|
112
|
+
raise ForbiddenException("This action is unauthorized")
|
|
113
|
+
app = ctx.app if ctx is not None else None
|
|
114
|
+
telemetry = app.telemetry if app is not None else None
|
|
115
|
+
v = Validator(
|
|
116
|
+
data,
|
|
117
|
+
self.rules(),
|
|
118
|
+
app=app,
|
|
119
|
+
messages=self.messages(),
|
|
120
|
+
attributes=self.attributes(),
|
|
121
|
+
ctx=ctx,
|
|
122
|
+
stop_on_first_failure=self.stop_on_first_failure,
|
|
123
|
+
)
|
|
124
|
+
await self._maybe_await(self.with_validator(v))
|
|
125
|
+
await v.fails()
|
|
126
|
+
await self._maybe_await(self.after(v))
|
|
127
|
+
if v.errors():
|
|
128
|
+
self._errors = v.errors()
|
|
129
|
+
if telemetry is not None:
|
|
130
|
+
telemetry.emit("form.failed", request=type(self).__name__, fields=len(self._errors))
|
|
131
|
+
raise ValidationException(self._errors, data)
|
|
132
|
+
self._validated = v.validated()
|
|
133
|
+
self._errors = {}
|
|
134
|
+
if telemetry is not None:
|
|
135
|
+
telemetry.emit("form.validated", request=type(self).__name__)
|