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.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ *.egg-info/
8
+ .coverage
9
+ .tox/
@@ -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