nyxa-forms 0.0.1__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 NyxaDev
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,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: nyxa-forms
3
+ Version: 0.0.1
4
+ Summary: Form requests + web validation for Nyxa — rule strings, error bags, old input
5
+ Author-email: Al-Amin Islam Nerob <alamin@aincoder.com>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/NyxaDev/Nyxa
8
+ Requires-Python: >=3.13
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: nyxa>=0.0.1
12
+ Dynamic: license-file
13
+
14
+ # nyxa-forms
15
+
16
+ Web validation for the Nyxa framework — injectable `FormRequest`
17
+ classes, ~115 pipe-separated string rules, custom rules via `@rule`,
18
+ error bags + old input flashing, and redirect-back semantics for HTML
19
+ forms.
20
+
21
+ ## Features
22
+
23
+ - **`FormRequest`** — self-validating request classes injected by type
24
+ into handlers: `rules()`, `authorize(ctx)`, `messages()`,
25
+ `attributes()`, `prepare_for_validation()`, `with_validator()`,
26
+ `after()`, `validated(*keys)`, `errors()`, `input(key)`.
27
+ - **`Validator`** — `Validator(data, rules, app=, ctx=)`;
28
+ `await v.validate()`, `v.validated()`, plus `validate_sync` for
29
+ non-async paths. Dot-key paths reach into nested payloads.
30
+ - **~115 built-in rules** — presence (`required`, `filled`,
31
+ `present`, `missing`, `prohibited` and their `*_if`/`*_unless`/
32
+ `*_with`/`*_without` variants), exclusion (`exclude*`), comparison
33
+ (`same`, `different`, `gt`/`gte`/`lt`/`lte`, `between`,
34
+ `in`/`not_in`/`in_array`/`any_of`, `multiple_of`), strings
35
+ (`alpha*`, `ascii`, `starts_with`/`ends_with`/`contains`(+`doesnt_*`),
36
+ `regex`/`not_regex`, `lowercase`/`uppercase`, `hex_color`), types
37
+ (`string`, `boolean`, `integer`, `numeric`, `decimal`, `list`,
38
+ `array`, `json`, `file`, `image`, `mimes`/`mimetypes`,
39
+ `extensions`, `dimensions`), numerics (`min`/`max`/`size`,
40
+ `digits*`), dates (`date`, `before`/`after`(+`_or_equal`),
41
+ `date_equals`, `date_format`, `timezone`), network (`url`,
42
+ `active_url`, `ip`/`ipv4`/`ipv6`, `mac_address`), identifiers
43
+ (`uuid`, `ulid`, `email`, `name`, `encoding`), database (`exists`,
44
+ `unique`, `current_password`), and `confirmed`/`nullable`/
45
+ `distinct`/`prohibits`/`enum`.
46
+ - **Rule objects** — `Password(min=8, letters=…, mixed_case=…,
47
+ numbers=…, symbols=…)` (alias `PasswordRule`) and `Enum(MyEnum)`
48
+ mix into rule specs alongside strings.
49
+ - **`@rule`** — register custom rules:
50
+ `@rule` / `@rule("name")` / `@rule(implicit=True, exclude=True,
51
+ needs_field=True)`; custom rules can shadow built-in names.
52
+ - **HTML form semantics** — `FormsExceptionHandler` wraps the app's
53
+ handler: a `ValidationException` on a non-JSON request redirects
54
+ back (303) with errors + old input flashed; JSON requests get 422.
55
+ - **`ctx` macros** — `ctx.validate_web(rules)`, `ctx.old(key)`,
56
+ `ctx.errors([key])`, `ctx.back(fallback="/")`.
57
+ - **Helpers** — `old(ctx, key)`, `errors(ctx[, key])`,
58
+ `with_errors(ctx, bag)`, `flash_old(ctx, data)`,
59
+ `back(ctx, fallback="/")` from `nyxa_forms.helpers`.
60
+ - **`make:request`** — `nyxa make:request StorePostRequest` scaffolds
61
+ a `FormRequest` (`--fields name:type[:rules]`, `--update`,
62
+ `--describe`, `--force`).
63
+
64
+ ## Install
65
+
66
+ ```bash
67
+ nyxa add nyxa-forms # writes config/forms.py
68
+ ```
69
+
70
+ ## Usage
71
+
72
+ ```python
73
+ from nyxa_forms import FormRequest, Password
74
+
75
+
76
+ class StorePost(FormRequest):
77
+ def rules(self):
78
+ return {"title": "required|string|max:255", "password": ["required", Password(min=12, numbers=True)]}
79
+
80
+ def authorize(self, ctx):
81
+ return ctx.user is not None
82
+
83
+
84
+ async def store(ctx, request: StorePost):
85
+ data = request.validated() # only keys that passed rules
86
+ ```
87
+
88
+ Inline validation + manual error handling:
89
+
90
+ ```python
91
+ async def handler(ctx):
92
+ data = await ctx.validate_web({"email": "required|email"})
93
+ ```
94
+
95
+ Custom rule:
96
+
97
+ ```python
98
+ from nyxa_forms import rule
99
+
100
+
101
+ @rule
102
+ def strong(value, params, data, validator):
103
+ return isinstance(value, str) and len(value) >= 12 or "Too weak."
104
+ ```
105
+
106
+ ## Public API
107
+
108
+ - **`FormRequest`** — injectable base (see Usage). Handler params
109
+ typed to a subclass are decoded, authorized, and validated by the
110
+ `FormRequestKind` contract.
111
+ - **`Validator`** — `Validator(data, rules, app=None, ctx=None)`;
112
+ `await v.validate() -> dict`, `v.validated() -> dict`,
113
+ `v.errors`, `v.messages`/`v.attributes` overrides.
114
+ - **`ValidationException`** — raised on failure (HTTP 422):
115
+ `exc.bag` (`{field: [messages]}`), `exc.data` (submitted payload).
116
+ - **`rule`** — decorator registering a rule function
117
+ `(value, params, data, validator) -> bool | str`; keyword flags
118
+ `implicit`, `exclude`, `needs_field`.
119
+ - **`Password`** — password-policy rule object:
120
+ `Password(min=8, letters=False, mixed_case=False, numbers=False,
121
+ symbols=False)`; `Password.defaults()`.
122
+ - **`PasswordRule`** — alias of `Password`.
123
+ - **`Enum`** — enum rule object: `Enum(MyEnum)` accepts member
124
+ instances, backing values, and names.
125
+ - **`FormsProvider`** — registers the `FormRequest` contract kind,
126
+ wraps the exception handler, adds the `ctx` macros, contributes
127
+ `make:request`, and writes `config/forms.py` on `nyxa add`.
128
+
129
+ ## Configuration
130
+
131
+ `nyxa add nyxa-forms` writes `config/forms.py` (`FORMS = {}` —
132
+ reserved for validation behavior overrides).
133
+
134
+ ## Testing
135
+
136
+ ```python
137
+ v = Validator({"email": "nope"}, {"email": "required|email"})
138
+ try:
139
+ await v.validate()
140
+ except ValidationException as e:
141
+ assert "email" in e.bag
142
+ ```
143
+
144
+ ## License
145
+
146
+ MIT
@@ -0,0 +1,133 @@
1
+ # nyxa-forms
2
+
3
+ Web validation for the Nyxa framework — injectable `FormRequest`
4
+ classes, ~115 pipe-separated string rules, custom rules via `@rule`,
5
+ error bags + old input flashing, and redirect-back semantics for HTML
6
+ forms.
7
+
8
+ ## Features
9
+
10
+ - **`FormRequest`** — self-validating request classes injected by type
11
+ into handlers: `rules()`, `authorize(ctx)`, `messages()`,
12
+ `attributes()`, `prepare_for_validation()`, `with_validator()`,
13
+ `after()`, `validated(*keys)`, `errors()`, `input(key)`.
14
+ - **`Validator`** — `Validator(data, rules, app=, ctx=)`;
15
+ `await v.validate()`, `v.validated()`, plus `validate_sync` for
16
+ non-async paths. Dot-key paths reach into nested payloads.
17
+ - **~115 built-in rules** — presence (`required`, `filled`,
18
+ `present`, `missing`, `prohibited` and their `*_if`/`*_unless`/
19
+ `*_with`/`*_without` variants), exclusion (`exclude*`), comparison
20
+ (`same`, `different`, `gt`/`gte`/`lt`/`lte`, `between`,
21
+ `in`/`not_in`/`in_array`/`any_of`, `multiple_of`), strings
22
+ (`alpha*`, `ascii`, `starts_with`/`ends_with`/`contains`(+`doesnt_*`),
23
+ `regex`/`not_regex`, `lowercase`/`uppercase`, `hex_color`), types
24
+ (`string`, `boolean`, `integer`, `numeric`, `decimal`, `list`,
25
+ `array`, `json`, `file`, `image`, `mimes`/`mimetypes`,
26
+ `extensions`, `dimensions`), numerics (`min`/`max`/`size`,
27
+ `digits*`), dates (`date`, `before`/`after`(+`_or_equal`),
28
+ `date_equals`, `date_format`, `timezone`), network (`url`,
29
+ `active_url`, `ip`/`ipv4`/`ipv6`, `mac_address`), identifiers
30
+ (`uuid`, `ulid`, `email`, `name`, `encoding`), database (`exists`,
31
+ `unique`, `current_password`), and `confirmed`/`nullable`/
32
+ `distinct`/`prohibits`/`enum`.
33
+ - **Rule objects** — `Password(min=8, letters=…, mixed_case=…,
34
+ numbers=…, symbols=…)` (alias `PasswordRule`) and `Enum(MyEnum)`
35
+ mix into rule specs alongside strings.
36
+ - **`@rule`** — register custom rules:
37
+ `@rule` / `@rule("name")` / `@rule(implicit=True, exclude=True,
38
+ needs_field=True)`; custom rules can shadow built-in names.
39
+ - **HTML form semantics** — `FormsExceptionHandler` wraps the app's
40
+ handler: a `ValidationException` on a non-JSON request redirects
41
+ back (303) with errors + old input flashed; JSON requests get 422.
42
+ - **`ctx` macros** — `ctx.validate_web(rules)`, `ctx.old(key)`,
43
+ `ctx.errors([key])`, `ctx.back(fallback="/")`.
44
+ - **Helpers** — `old(ctx, key)`, `errors(ctx[, key])`,
45
+ `with_errors(ctx, bag)`, `flash_old(ctx, data)`,
46
+ `back(ctx, fallback="/")` from `nyxa_forms.helpers`.
47
+ - **`make:request`** — `nyxa make:request StorePostRequest` scaffolds
48
+ a `FormRequest` (`--fields name:type[:rules]`, `--update`,
49
+ `--describe`, `--force`).
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ nyxa add nyxa-forms # writes config/forms.py
55
+ ```
56
+
57
+ ## Usage
58
+
59
+ ```python
60
+ from nyxa_forms import FormRequest, Password
61
+
62
+
63
+ class StorePost(FormRequest):
64
+ def rules(self):
65
+ return {"title": "required|string|max:255", "password": ["required", Password(min=12, numbers=True)]}
66
+
67
+ def authorize(self, ctx):
68
+ return ctx.user is not None
69
+
70
+
71
+ async def store(ctx, request: StorePost):
72
+ data = request.validated() # only keys that passed rules
73
+ ```
74
+
75
+ Inline validation + manual error handling:
76
+
77
+ ```python
78
+ async def handler(ctx):
79
+ data = await ctx.validate_web({"email": "required|email"})
80
+ ```
81
+
82
+ Custom rule:
83
+
84
+ ```python
85
+ from nyxa_forms import rule
86
+
87
+
88
+ @rule
89
+ def strong(value, params, data, validator):
90
+ return isinstance(value, str) and len(value) >= 12 or "Too weak."
91
+ ```
92
+
93
+ ## Public API
94
+
95
+ - **`FormRequest`** — injectable base (see Usage). Handler params
96
+ typed to a subclass are decoded, authorized, and validated by the
97
+ `FormRequestKind` contract.
98
+ - **`Validator`** — `Validator(data, rules, app=None, ctx=None)`;
99
+ `await v.validate() -> dict`, `v.validated() -> dict`,
100
+ `v.errors`, `v.messages`/`v.attributes` overrides.
101
+ - **`ValidationException`** — raised on failure (HTTP 422):
102
+ `exc.bag` (`{field: [messages]}`), `exc.data` (submitted payload).
103
+ - **`rule`** — decorator registering a rule function
104
+ `(value, params, data, validator) -> bool | str`; keyword flags
105
+ `implicit`, `exclude`, `needs_field`.
106
+ - **`Password`** — password-policy rule object:
107
+ `Password(min=8, letters=False, mixed_case=False, numbers=False,
108
+ symbols=False)`; `Password.defaults()`.
109
+ - **`PasswordRule`** — alias of `Password`.
110
+ - **`Enum`** — enum rule object: `Enum(MyEnum)` accepts member
111
+ instances, backing values, and names.
112
+ - **`FormsProvider`** — registers the `FormRequest` contract kind,
113
+ wraps the exception handler, adds the `ctx` macros, contributes
114
+ `make:request`, and writes `config/forms.py` on `nyxa add`.
115
+
116
+ ## Configuration
117
+
118
+ `nyxa add nyxa-forms` writes `config/forms.py` (`FORMS = {}` —
119
+ reserved for validation behavior overrides).
120
+
121
+ ## Testing
122
+
123
+ ```python
124
+ v = Validator({"email": "nope"}, {"email": "required|email"})
125
+ try:
126
+ await v.validate()
127
+ except ValidationException as e:
128
+ assert "email" in e.bag
129
+ ```
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=75"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nyxa-forms"
7
+ version = "0.0.1"
8
+ description = "Form requests + web validation for Nyxa — rule strings, error bags, old input"
9
+ readme = "README.md"
10
+ authors = [{ name = "Al-Amin Islam Nerob", email = "alamin@aincoder.com" }]
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ requires-python = ">=3.13"
14
+ dependencies = ["nyxa>=0.0.1"]
15
+
16
+ [project.urls]
17
+ Repository = "https://github.com/NyxaDev/Nyxa"
18
+
19
+ [project.entry-points."nyxa.plugins"]
20
+ forms = "nyxa_forms.provider:FormsProvider"
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+
25
+ [tool.setuptools.package-data]
26
+ nyxa_forms = ["stubs/*.stub"]
27
+
28
+ [tool.pytest.ini_options]
29
+ asyncio_mode = "auto"
30
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -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
@@ -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)
@@ -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)
@@ -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 {}