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.
- nyxa_forms-0.0.1/LICENSE +21 -0
- nyxa_forms-0.0.1/PKG-INFO +146 -0
- nyxa_forms-0.0.1/README.md +133 -0
- nyxa_forms-0.0.1/pyproject.toml +30 -0
- nyxa_forms-0.0.1/setup.cfg +4 -0
- nyxa_forms-0.0.1/src/nyxa_forms/__init__.py +23 -0
- nyxa_forms-0.0.1/src/nyxa_forms/commands.py +71 -0
- nyxa_forms-0.0.1/src/nyxa_forms/handler.py +35 -0
- nyxa_forms-0.0.1/src/nyxa_forms/helpers.py +134 -0
- nyxa_forms-0.0.1/src/nyxa_forms/kind.py +58 -0
- nyxa_forms-0.0.1/src/nyxa_forms/provider.py +109 -0
- nyxa_forms-0.0.1/src/nyxa_forms/request.py +135 -0
- nyxa_forms-0.0.1/src/nyxa_forms/stubs/config_forms.stub +3 -0
- nyxa_forms-0.0.1/src/nyxa_forms/validator.py +2036 -0
- nyxa_forms-0.0.1/src/nyxa_forms.egg-info/PKG-INFO +146 -0
- nyxa_forms-0.0.1/src/nyxa_forms.egg-info/SOURCES.txt +20 -0
- nyxa_forms-0.0.1/src/nyxa_forms.egg-info/dependency_links.txt +1 -0
- nyxa_forms-0.0.1/src/nyxa_forms.egg-info/entry_points.txt +2 -0
- nyxa_forms-0.0.1/src/nyxa_forms.egg-info/requires.txt +1 -0
- nyxa_forms-0.0.1/src/nyxa_forms.egg-info/top_level.txt +1 -0
- nyxa_forms-0.0.1/tests/test_form_rule_strings.py +694 -0
- nyxa_forms-0.0.1/tests/test_forms.py +283 -0
nyxa_forms-0.0.1/LICENSE
ADDED
|
@@ -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,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 {}
|