wred-pydantic 0.1.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.
- wred_pydantic-0.1.0/.gitignore +9 -0
- wred_pydantic-0.1.0/LICENSE +29 -0
- wred_pydantic-0.1.0/PKG-INFO +94 -0
- wred_pydantic-0.1.0/README.md +78 -0
- wred_pydantic-0.1.0/benchmarks/validation.py +80 -0
- wred_pydantic-0.1.0/pyproject.toml +20 -0
- wred_pydantic-0.1.0/tests/test_validation.py +211 -0
- wred_pydantic-0.1.0/tox.ini +12 -0
- wred_pydantic-0.1.0/wred_pydantic/__init__.py +180 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Harrison Erd
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its contributors
|
|
17
|
+
may be used to endorse or promote products derived from this software
|
|
18
|
+
without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wred-pydantic
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Optional pydantic integration for Wred
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-Expression: BSD-3-Clause
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: wred>=0.13,<0.15
|
|
10
|
+
Requires-Dist: pydantic>=2.8,<3
|
|
11
|
+
Requires-Dist: pytest>=8 ; extra == "test"
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.24 ; extra == "test"
|
|
13
|
+
Requires-Dist: httpx>=0.27 ; extra == "test"
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
|
|
16
|
+
# wred-pydantic
|
|
17
|
+
|
|
18
|
+
Explicit Pydantic v2 input validation and response conversion for Wred 0.13–0.14.
|
|
19
|
+
Python 3.11+. No dependency injection or annotation inference.
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pip install .
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from pydantic import BaseModel, Field
|
|
27
|
+
from wred import App
|
|
28
|
+
from wred_pydantic import body, query, path, response
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Product(BaseModel):
|
|
32
|
+
name: str = Field(min_length=1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
app = App()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.route("/products", methods=["POST"])
|
|
39
|
+
@body(Product)
|
|
40
|
+
@response(Product, status=201)
|
|
41
|
+
async def create(request, body):
|
|
42
|
+
return body, [("content-type", "application/json")], 201
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `body(Model, argument='body', max_bytes=1048576)` validates raw JSON bytes and
|
|
46
|
+
injects a keyword argument. Bodies over the limit return 413. Set `max_bytes=None`
|
|
47
|
+
to disable the limit, or supply a nonnegative byte count.
|
|
48
|
+
- `query(Model, argument='query')` validates Wred's query mapping. Duplicate query keys
|
|
49
|
+
retain the last value; array query syntax is not inferred.
|
|
50
|
+
- `path(Model, argument='path')` validates route kwargs and injects a model while
|
|
51
|
+
forwarding the original route kwargs. Handlers must accept both.
|
|
52
|
+
- `response(Model, status=200, by_alias=True)` validates and serializes successful
|
|
53
|
+
handler returns. Serialization aliases are used in both JSON and schema; set
|
|
54
|
+
`by_alias=False` to use field names in both.
|
|
55
|
+
`Model` may also be a supported type such as `list[Product]`. `status` describes
|
|
56
|
+
OpenAPI metadata; the decorator does not change the returned HTTP status.
|
|
57
|
+
|
|
58
|
+
Invalid input returns 422 with `detail` and `errors` containing locations and types,
|
|
59
|
+
without raw inputs or custom validator messages. Model settings control coercion,
|
|
60
|
+
strictness, aliases, and extra fields. Invalid server responses raise Pydantic's
|
|
61
|
+
`ValidationError`; they are not converted to client errors. Tuples retain headers
|
|
62
|
+
and status. Non-2xx responses, 204/205 responses, and callable ASGI responses pass
|
|
63
|
+
through without validation. Successful payloads are validated regardless of the
|
|
64
|
+
metadata `status` value.
|
|
65
|
+
|
|
66
|
+
Use `@app.route` outermost, then input decorators, then response conversion.
|
|
67
|
+
Place exception mapping outside response conversion to handle response validation
|
|
68
|
+
failures. Injected argument names cannot collide with
|
|
69
|
+
route parameters. Schema metadata is consumed by `wred-openapi` without a dependency
|
|
70
|
+
on that package. Do not register duplicate metadata for the same input source.
|
|
71
|
+
|
|
72
|
+
Run `pip install -e '.[test]'` and `pytest` inside this repository.
|
|
73
|
+
|
|
74
|
+
The body limit counts actual streamed bytes, including requests without a
|
|
75
|
+
Content-Length header, and stops reading when exceeded. Cached bodies are also
|
|
76
|
+
checked, but middleware that reads the body first has already allocated that
|
|
77
|
+
memory. Configure body limits and read timeouts at the server or reverse proxy
|
|
78
|
+
as well. The decorator cannot prevent the ASGI server from buffering a large
|
|
79
|
+
individual chunk before delivering it.
|
|
80
|
+
|
|
81
|
+
Constrain collection lengths, string lengths, and nesting in your models, and
|
|
82
|
+
paginate large responses. Validation and serialization run synchronously in the
|
|
83
|
+
async worker; expensive custom validators and large payloads delay other requests
|
|
84
|
+
on that worker. The body limit is not a CPU or response-size limit. Model instances
|
|
85
|
+
follow Pydantic's revalidation policy; use `revalidate_instances='always'` if your
|
|
86
|
+
application returns models that may have been mutated after construction.
|
|
87
|
+
|
|
88
|
+
Run `tox` for the Python 3.12 compatibility matrix (Wred 0.13/0.14 and Pydantic
|
|
89
|
+
2.8.0/latest 2.x). Run `python benchmarks/validation.py` after installing this
|
|
90
|
+
package to compare plain Wred with request and response validation for 1, 100,
|
|
91
|
+
and 10,000 items. It reports in-process ASGI latency, excluding networking and
|
|
92
|
+
application I/O. Measure throughput, memory, and tail latency under your expected
|
|
93
|
+
concurrency before sizing a production deployment.
|
|
94
|
+
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# wred-pydantic
|
|
2
|
+
|
|
3
|
+
Explicit Pydantic v2 input validation and response conversion for Wred 0.13–0.14.
|
|
4
|
+
Python 3.11+. No dependency injection or annotation inference.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pip install .
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
from wred import App
|
|
13
|
+
from wred_pydantic import body, query, path, response
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Product(BaseModel):
|
|
17
|
+
name: str = Field(min_length=1)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
app = App()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.route("/products", methods=["POST"])
|
|
24
|
+
@body(Product)
|
|
25
|
+
@response(Product, status=201)
|
|
26
|
+
async def create(request, body):
|
|
27
|
+
return body, [("content-type", "application/json")], 201
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- `body(Model, argument='body', max_bytes=1048576)` validates raw JSON bytes and
|
|
31
|
+
injects a keyword argument. Bodies over the limit return 413. Set `max_bytes=None`
|
|
32
|
+
to disable the limit, or supply a nonnegative byte count.
|
|
33
|
+
- `query(Model, argument='query')` validates Wred's query mapping. Duplicate query keys
|
|
34
|
+
retain the last value; array query syntax is not inferred.
|
|
35
|
+
- `path(Model, argument='path')` validates route kwargs and injects a model while
|
|
36
|
+
forwarding the original route kwargs. Handlers must accept both.
|
|
37
|
+
- `response(Model, status=200, by_alias=True)` validates and serializes successful
|
|
38
|
+
handler returns. Serialization aliases are used in both JSON and schema; set
|
|
39
|
+
`by_alias=False` to use field names in both.
|
|
40
|
+
`Model` may also be a supported type such as `list[Product]`. `status` describes
|
|
41
|
+
OpenAPI metadata; the decorator does not change the returned HTTP status.
|
|
42
|
+
|
|
43
|
+
Invalid input returns 422 with `detail` and `errors` containing locations and types,
|
|
44
|
+
without raw inputs or custom validator messages. Model settings control coercion,
|
|
45
|
+
strictness, aliases, and extra fields. Invalid server responses raise Pydantic's
|
|
46
|
+
`ValidationError`; they are not converted to client errors. Tuples retain headers
|
|
47
|
+
and status. Non-2xx responses, 204/205 responses, and callable ASGI responses pass
|
|
48
|
+
through without validation. Successful payloads are validated regardless of the
|
|
49
|
+
metadata `status` value.
|
|
50
|
+
|
|
51
|
+
Use `@app.route` outermost, then input decorators, then response conversion.
|
|
52
|
+
Place exception mapping outside response conversion to handle response validation
|
|
53
|
+
failures. Injected argument names cannot collide with
|
|
54
|
+
route parameters. Schema metadata is consumed by `wred-openapi` without a dependency
|
|
55
|
+
on that package. Do not register duplicate metadata for the same input source.
|
|
56
|
+
|
|
57
|
+
Run `pip install -e '.[test]'` and `pytest` inside this repository.
|
|
58
|
+
|
|
59
|
+
The body limit counts actual streamed bytes, including requests without a
|
|
60
|
+
Content-Length header, and stops reading when exceeded. Cached bodies are also
|
|
61
|
+
checked, but middleware that reads the body first has already allocated that
|
|
62
|
+
memory. Configure body limits and read timeouts at the server or reverse proxy
|
|
63
|
+
as well. The decorator cannot prevent the ASGI server from buffering a large
|
|
64
|
+
individual chunk before delivering it.
|
|
65
|
+
|
|
66
|
+
Constrain collection lengths, string lengths, and nesting in your models, and
|
|
67
|
+
paginate large responses. Validation and serialization run synchronously in the
|
|
68
|
+
async worker; expensive custom validators and large payloads delay other requests
|
|
69
|
+
on that worker. The body limit is not a CPU or response-size limit. Model instances
|
|
70
|
+
follow Pydantic's revalidation policy; use `revalidate_instances='always'` if your
|
|
71
|
+
application returns models that may have been mutated after construction.
|
|
72
|
+
|
|
73
|
+
Run `tox` for the Python 3.12 compatibility matrix (Wred 0.13/0.14 and Pydantic
|
|
74
|
+
2.8.0/latest 2.x). Run `python benchmarks/validation.py` after installing this
|
|
75
|
+
package to compare plain Wred with request and response validation for 1, 100,
|
|
76
|
+
and 10,000 items. It reports in-process ASGI latency, excluding networking and
|
|
77
|
+
application I/O. Measure throughput, memory, and tail latency under your expected
|
|
78
|
+
concurrency before sizing a production deployment.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""In-process ASGI cost comparison; no network or production throughput claims.
|
|
2
|
+
|
|
3
|
+
Run: python benchmarks/validation.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import statistics
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import pydantic
|
|
12
|
+
import wred
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from wred_pydantic import body, response
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Item(BaseModel):
|
|
19
|
+
id: int
|
|
20
|
+
name: str
|
|
21
|
+
price: float
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def application(validated):
|
|
25
|
+
app = wred.App()
|
|
26
|
+
if validated:
|
|
27
|
+
|
|
28
|
+
@body(list[Item])
|
|
29
|
+
@response(list[Item])
|
|
30
|
+
async def handler(request, body):
|
|
31
|
+
return body
|
|
32
|
+
else:
|
|
33
|
+
|
|
34
|
+
async def handler(request):
|
|
35
|
+
return await request.json()
|
|
36
|
+
|
|
37
|
+
app.route("/", methods=["POST"])(handler)
|
|
38
|
+
return app
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def measure(app, payload, iterations):
|
|
42
|
+
scope = {"type": "http", "method": "POST", "path": "/"}
|
|
43
|
+
|
|
44
|
+
async def receive():
|
|
45
|
+
return {"type": "http.request", "body": payload, "more_body": False}
|
|
46
|
+
|
|
47
|
+
async def send(message):
|
|
48
|
+
if message["type"] == "http.response.start":
|
|
49
|
+
assert message["status"] == 200
|
|
50
|
+
|
|
51
|
+
for _ in range(20):
|
|
52
|
+
await app(scope, receive, send)
|
|
53
|
+
samples = []
|
|
54
|
+
for _ in range(iterations):
|
|
55
|
+
start = time.perf_counter_ns()
|
|
56
|
+
await app(scope, receive, send)
|
|
57
|
+
samples.append((time.perf_counter_ns() - start) / 1000)
|
|
58
|
+
return statistics.median(samples), sorted(samples)[int(len(samples) * 0.95)]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def main():
|
|
62
|
+
print(f"Wred {wred.__version__}; Pydantic {pydantic.__version__}")
|
|
63
|
+
print("items bytes mode median_us p95_us")
|
|
64
|
+
for count, iterations in [(1, 5000), (100, 1000), (10000, 100)]:
|
|
65
|
+
payload = json.dumps(
|
|
66
|
+
[{"id": i, "name": "example product", "price": 12.50} for i in range(count)]
|
|
67
|
+
).encode()
|
|
68
|
+
for validated in (False, True):
|
|
69
|
+
median, p95 = await measure(application(validated), payload, iterations)
|
|
70
|
+
print(
|
|
71
|
+
count,
|
|
72
|
+
len(payload),
|
|
73
|
+
"validated" if validated else "plain",
|
|
74
|
+
f"{median:.1f}",
|
|
75
|
+
f"{p95:.1f}",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["flit_core>=3.12,<4"]
|
|
3
|
+
build-backend = "flit_core.buildapi"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wred-pydantic"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Optional pydantic integration for Wred"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "BSD-3-Clause"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = ["wred>=0.13,<0.15", "pydantic>=2.8,<3"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
test = ["pytest>=8", "pytest-asyncio>=0.24", "httpx>=0.27"]
|
|
17
|
+
|
|
18
|
+
[tool.pytest.ini_options]
|
|
19
|
+
asyncio_mode = "auto"
|
|
20
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import pytest
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
7
|
+
from wred import App
|
|
8
|
+
|
|
9
|
+
from wred_pydantic import body, path, query, response
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Input(BaseModel):
|
|
13
|
+
name: str = Field(min_length=1, alias="label")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Query(BaseModel):
|
|
17
|
+
count: int = Field(ge=1)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Path(BaseModel):
|
|
21
|
+
model_config = ConfigDict(extra="forbid")
|
|
22
|
+
item_id: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Output(BaseModel):
|
|
26
|
+
at: datetime
|
|
27
|
+
names: list[str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def test_nested_composition_and_errors():
|
|
31
|
+
app = App()
|
|
32
|
+
|
|
33
|
+
@app.route("/items/{item_id}", methods=["POST"])
|
|
34
|
+
@body(Input)
|
|
35
|
+
@query(Query)
|
|
36
|
+
@path(Path)
|
|
37
|
+
@response(Output, status=201)
|
|
38
|
+
async def handler(request, item_id, body, query, path):
|
|
39
|
+
assert path.item_id == int(item_id)
|
|
40
|
+
return (
|
|
41
|
+
{
|
|
42
|
+
"at": datetime(2026, 1, 1, tzinfo=UTC),
|
|
43
|
+
"names": [body.name] * query.count,
|
|
44
|
+
},
|
|
45
|
+
[("content-type", "application/json"), ("x-test", "ok")],
|
|
46
|
+
201,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
async with httpx.AsyncClient(
|
|
50
|
+
transport=httpx.ASGITransport(app), base_url="http://test"
|
|
51
|
+
) as client:
|
|
52
|
+
result = await client.post("/items/3?count=2", json={"label": "hello"})
|
|
53
|
+
assert result.status_code == 201 and result.headers["x-test"] == "ok"
|
|
54
|
+
assert result.json() == {
|
|
55
|
+
"at": "2026-01-01T00:00:00Z",
|
|
56
|
+
"names": ["hello", "hello"],
|
|
57
|
+
}
|
|
58
|
+
for url, data in [
|
|
59
|
+
("/items/3?count=0", {"label": "secret"}),
|
|
60
|
+
("/items/no?count=1", {"label": "secret"}),
|
|
61
|
+
("/items/3?count=1", {"label": ""}),
|
|
62
|
+
]:
|
|
63
|
+
result = await client.post(url, json=data)
|
|
64
|
+
assert result.status_code == 422 and "secret" not in result.text
|
|
65
|
+
assert (await client.post("/items/3?count=1", content=b"{")).status_code == 422
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def test_response_failure_is_not_client_error_and_asgi_untouched():
|
|
69
|
+
@response(Output)
|
|
70
|
+
async def broken(request):
|
|
71
|
+
return {"names": []}
|
|
72
|
+
|
|
73
|
+
with pytest.raises(ValidationError):
|
|
74
|
+
await broken(None)
|
|
75
|
+
|
|
76
|
+
async def asgi(scope, receive, send):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
@response(Output)
|
|
80
|
+
async def streaming(request):
|
|
81
|
+
return asgi
|
|
82
|
+
|
|
83
|
+
assert await streaming(None) is asgi
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.parametrize("by_alias, key", [(True, "displayName"), (False, "name")])
|
|
87
|
+
async def test_response_alias_matches_schema(by_alias, key):
|
|
88
|
+
class Aliased(BaseModel):
|
|
89
|
+
name: str = Field(serialization_alias="displayName")
|
|
90
|
+
|
|
91
|
+
app = App()
|
|
92
|
+
|
|
93
|
+
@app.route("/")
|
|
94
|
+
@response(Aliased, by_alias=by_alias)
|
|
95
|
+
async def handler(request):
|
|
96
|
+
return {"name": "hello"}
|
|
97
|
+
|
|
98
|
+
async with httpx.AsyncClient(
|
|
99
|
+
transport=httpx.ASGITransport(app), base_url="http://test"
|
|
100
|
+
) as client:
|
|
101
|
+
result = await client.get("/")
|
|
102
|
+
assert result.json() == {key: "hello"}
|
|
103
|
+
schema = handler.__wred_openapi__["responses"]["200"]["content"][
|
|
104
|
+
"application/json"
|
|
105
|
+
]["schema"]
|
|
106
|
+
assert set(schema["properties"]) == set(result.json())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.parametrize("status", [302, 400, 404, 422, 500, 204, 205])
|
|
110
|
+
async def test_response_preserves_errors_redirects_and_empty_statuses(status):
|
|
111
|
+
app = App()
|
|
112
|
+
payload = b"" if status in (204, 205) else {"detail": "not a success model"}
|
|
113
|
+
|
|
114
|
+
@app.route("/")
|
|
115
|
+
@response(Output)
|
|
116
|
+
async def handler(request):
|
|
117
|
+
return payload, [("x-test", "preserved")], status
|
|
118
|
+
|
|
119
|
+
async with httpx.AsyncClient(
|
|
120
|
+
transport=httpx.ASGITransport(app), base_url="http://test"
|
|
121
|
+
) as client:
|
|
122
|
+
result = await client.get("/")
|
|
123
|
+
assert result.status_code == status
|
|
124
|
+
assert result.headers["x-test"] == "preserved"
|
|
125
|
+
if status in (204, 205):
|
|
126
|
+
assert result.content == b""
|
|
127
|
+
else:
|
|
128
|
+
assert result.json() == payload
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.parametrize("size, expected", [(16, 200), (17, 413)])
|
|
132
|
+
async def test_body_limit_counts_streamed_bytes_and_stops_reading(size, expected):
|
|
133
|
+
app = App()
|
|
134
|
+
consumed = []
|
|
135
|
+
called = []
|
|
136
|
+
|
|
137
|
+
@app.route("/", methods=["POST"])
|
|
138
|
+
@body(str, max_bytes=16)
|
|
139
|
+
async def handler(request, body):
|
|
140
|
+
called.append(body)
|
|
141
|
+
return {"value": body}
|
|
142
|
+
|
|
143
|
+
async def chunks():
|
|
144
|
+
for chunk in (b'"', b"a" * (size - 2), b'"', b" " * 100):
|
|
145
|
+
consumed.append(chunk)
|
|
146
|
+
yield chunk
|
|
147
|
+
if expected == 200 and len(consumed) == 3:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
async with httpx.AsyncClient(
|
|
151
|
+
transport=httpx.ASGITransport(app), base_url="http://test"
|
|
152
|
+
) as client:
|
|
153
|
+
result = await client.post("/", content=chunks())
|
|
154
|
+
assert result.status_code == expected
|
|
155
|
+
assert len(consumed) == 3
|
|
156
|
+
assert bool(called) == (expected == 200)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def test_concurrent_requests_keep_original_path_arguments_isolated():
|
|
160
|
+
app = App()
|
|
161
|
+
arrived = 0
|
|
162
|
+
ready = asyncio.Event()
|
|
163
|
+
|
|
164
|
+
def pause(handler):
|
|
165
|
+
async def wrapped(request, **kwargs):
|
|
166
|
+
nonlocal arrived
|
|
167
|
+
arrived += 1
|
|
168
|
+
if arrived == 2:
|
|
169
|
+
ready.set()
|
|
170
|
+
await asyncio.wait_for(ready.wait(), timeout=2)
|
|
171
|
+
return await handler(request, **kwargs)
|
|
172
|
+
|
|
173
|
+
return wrapped
|
|
174
|
+
|
|
175
|
+
@app.route("/{item_id}", methods=["POST"])
|
|
176
|
+
@body(Input)
|
|
177
|
+
@pause
|
|
178
|
+
@path(Path)
|
|
179
|
+
async def handler(request, item_id, body, path):
|
|
180
|
+
return {"id": path.item_id, "name": body.name}
|
|
181
|
+
|
|
182
|
+
async with httpx.AsyncClient(
|
|
183
|
+
transport=httpx.ASGITransport(app), base_url="http://test"
|
|
184
|
+
) as client:
|
|
185
|
+
results = await asyncio.gather(
|
|
186
|
+
*[client.post(f"/{i}", json={"label": str(i)}) for i in (1, 2)]
|
|
187
|
+
)
|
|
188
|
+
assert [r.json() for r in results] == [
|
|
189
|
+
{"id": 1, "name": "1"},
|
|
190
|
+
{"id": 2, "name": "2"},
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@pytest.mark.parametrize("limit, expected", [(3, 413), (None, 200)])
|
|
195
|
+
async def test_body_limit_applies_to_cached_body_and_can_be_disabled(limit, expected):
|
|
196
|
+
app = App()
|
|
197
|
+
|
|
198
|
+
@body(str, max_bytes=limit)
|
|
199
|
+
async def validated(request, body):
|
|
200
|
+
return {"value": body}
|
|
201
|
+
|
|
202
|
+
@app.route("/", methods=["POST"])
|
|
203
|
+
async def handler(request):
|
|
204
|
+
await request.body()
|
|
205
|
+
return await validated(request)
|
|
206
|
+
|
|
207
|
+
async with httpx.AsyncClient(
|
|
208
|
+
transport=httpx.ASGITransport(app), base_url="http://test"
|
|
209
|
+
) as client:
|
|
210
|
+
result = await client.post("/", json="hello")
|
|
211
|
+
assert result.status_code == expected
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[tox]
|
|
2
|
+
env_list = py312-wred{013,014}-pydantic{min,latest}
|
|
3
|
+
|
|
4
|
+
[testenv]
|
|
5
|
+
package = wheel
|
|
6
|
+
extras = test
|
|
7
|
+
deps =
|
|
8
|
+
wred013: wred==0.13
|
|
9
|
+
wred014: wred==0.14
|
|
10
|
+
pydanticmin: pydantic==2.8.0
|
|
11
|
+
pydanticlatest: pydantic>=2.8,<3
|
|
12
|
+
commands = python -m pytest -q
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Explicit Pydantic v2 validation and serialization for Wred."""
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
from functools import wraps
|
|
5
|
+
|
|
6
|
+
from pydantic import TypeAdapter, ValidationError
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
# Task-local: nested input decorators share only the original route arguments.
|
|
11
|
+
_route_context = ContextVar("wred_pydantic_route", default=None)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _BodyTooLarge(Exception):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _read_body(request, max_bytes):
|
|
19
|
+
if max_bytes is None:
|
|
20
|
+
return await request.body()
|
|
21
|
+
receive = request.receive
|
|
22
|
+
size = 0
|
|
23
|
+
|
|
24
|
+
async def limited_receive():
|
|
25
|
+
nonlocal size
|
|
26
|
+
message = await receive()
|
|
27
|
+
size += len(message.get("body", b""))
|
|
28
|
+
if size > max_bytes:
|
|
29
|
+
raise _BodyTooLarge
|
|
30
|
+
return message
|
|
31
|
+
|
|
32
|
+
request.receive = limited_receive
|
|
33
|
+
try:
|
|
34
|
+
data = await request.body()
|
|
35
|
+
finally:
|
|
36
|
+
request.receive = receive
|
|
37
|
+
# Also enforce the limit if middleware already cached the body.
|
|
38
|
+
if len(data) > max_bytes:
|
|
39
|
+
raise _BodyTooLarge
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _metadata(handler, **entries):
|
|
44
|
+
metadata = dict(getattr(handler, "__wred_openapi__", {}))
|
|
45
|
+
for key, value in entries.items():
|
|
46
|
+
if key == "responses":
|
|
47
|
+
merged = dict(metadata.get(key, {}))
|
|
48
|
+
for status, definition in value.items():
|
|
49
|
+
if status in merged and merged[status] != definition:
|
|
50
|
+
raise ValueError(f"Conflicting response metadata: {status}")
|
|
51
|
+
merged[status] = definition
|
|
52
|
+
metadata[key] = merged
|
|
53
|
+
else:
|
|
54
|
+
if key in metadata and metadata[key] != value:
|
|
55
|
+
raise ValueError(f"Conflicting {key} metadata")
|
|
56
|
+
metadata[key] = value
|
|
57
|
+
handler.__wred_openapi__ = metadata
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _validate(model, source, argument, max_bytes=None):
|
|
61
|
+
adapter = TypeAdapter(model)
|
|
62
|
+
if not argument or not argument.isidentifier() or argument == "request":
|
|
63
|
+
raise ValueError("Use a named argument other than request")
|
|
64
|
+
|
|
65
|
+
def decorate(handler):
|
|
66
|
+
@wraps(handler)
|
|
67
|
+
async def wrapped(request, *args, **kwargs):
|
|
68
|
+
if argument in kwargs:
|
|
69
|
+
raise TypeError(
|
|
70
|
+
f"Validated argument conflicts with path parameter: {argument}"
|
|
71
|
+
)
|
|
72
|
+
context = _route_context.get()
|
|
73
|
+
route_kwargs = (
|
|
74
|
+
context[1] if context is not None and context[0] is request else kwargs
|
|
75
|
+
)
|
|
76
|
+
try:
|
|
77
|
+
if source == "body":
|
|
78
|
+
value = adapter.validate_json(await _read_body(request, max_bytes))
|
|
79
|
+
else:
|
|
80
|
+
value = adapter.validate_python(
|
|
81
|
+
request.query if source == "query" else route_kwargs
|
|
82
|
+
)
|
|
83
|
+
except _BodyTooLarge:
|
|
84
|
+
return (
|
|
85
|
+
{"detail": "Request body too large"},
|
|
86
|
+
[("content-type", "application/json")],
|
|
87
|
+
413,
|
|
88
|
+
)
|
|
89
|
+
except ValidationError as exc:
|
|
90
|
+
errors = [
|
|
91
|
+
{"loc": list(e["loc"]), "type": e["type"]}
|
|
92
|
+
for e in exc.errors(
|
|
93
|
+
include_input=False, include_context=False, include_url=False
|
|
94
|
+
)
|
|
95
|
+
]
|
|
96
|
+
return (
|
|
97
|
+
{"detail": "Invalid request", "errors": errors},
|
|
98
|
+
[("content-type", "application/json")],
|
|
99
|
+
422,
|
|
100
|
+
)
|
|
101
|
+
except (UnicodeError, ValueError):
|
|
102
|
+
if source != "query":
|
|
103
|
+
raise
|
|
104
|
+
return (
|
|
105
|
+
{"detail": "Invalid query encoding"},
|
|
106
|
+
[("content-type", "application/json")],
|
|
107
|
+
422,
|
|
108
|
+
)
|
|
109
|
+
token = _route_context.set((request, route_kwargs))
|
|
110
|
+
try:
|
|
111
|
+
return await handler(request, *args, **kwargs, **{argument: value})
|
|
112
|
+
finally:
|
|
113
|
+
_route_context.reset(token)
|
|
114
|
+
|
|
115
|
+
_metadata(wrapped, **{source: adapter.json_schema()})
|
|
116
|
+
return wrapped
|
|
117
|
+
|
|
118
|
+
return decorate
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def body(model, *, argument="body", max_bytes=1024 * 1024):
|
|
122
|
+
"""Validate raw JSON bytes and inject a model as a keyword argument."""
|
|
123
|
+
if max_bytes is not None and (type(max_bytes) is not int or max_bytes < 0):
|
|
124
|
+
raise ValueError("max_bytes must be a nonnegative integer or None")
|
|
125
|
+
return _validate(model, "body", argument, max_bytes)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def query(model, *, argument="query"):
|
|
129
|
+
"""Validate Wred's query mapping (repeated keys retain their last value)."""
|
|
130
|
+
return _validate(model, "query", argument)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def path(model, *, argument="path"):
|
|
134
|
+
"""Validate route kwargs, preserving the original kwargs too."""
|
|
135
|
+
return _validate(model, "path", argument)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def response(model, *, status=200, by_alias=True):
|
|
139
|
+
"""Validate and JSON-normalize a response body, preserving tuples and ASGI."""
|
|
140
|
+
adapter = TypeAdapter(model)
|
|
141
|
+
|
|
142
|
+
def decorate(handler):
|
|
143
|
+
@wraps(handler)
|
|
144
|
+
async def wrapped(request, *args, **kwargs):
|
|
145
|
+
result = await handler(request, *args, **kwargs)
|
|
146
|
+
if callable(result):
|
|
147
|
+
return result
|
|
148
|
+
if isinstance(result, tuple):
|
|
149
|
+
payload, _headers, actual_status = result
|
|
150
|
+
if not 200 <= actual_status < 300 or actual_status in (204, 205):
|
|
151
|
+
return result
|
|
152
|
+
else:
|
|
153
|
+
payload = result
|
|
154
|
+
normalized = adapter.dump_python(
|
|
155
|
+
adapter.validate_python(payload), mode="json", by_alias=by_alias
|
|
156
|
+
)
|
|
157
|
+
return (
|
|
158
|
+
(normalized, result[1], result[2])
|
|
159
|
+
if isinstance(result, tuple)
|
|
160
|
+
else normalized
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
_metadata(
|
|
164
|
+
wrapped,
|
|
165
|
+
responses={
|
|
166
|
+
str(status): {
|
|
167
|
+
"description": "Successful response",
|
|
168
|
+
"content": {
|
|
169
|
+
"application/json": {
|
|
170
|
+
"schema": adapter.json_schema(
|
|
171
|
+
mode="serialization", by_alias=by_alias
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
return wrapped
|
|
179
|
+
|
|
180
|
+
return decorate
|