msgspec-fastapi 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,45 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v[0-9]*"
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
16
+ with:
17
+ persist-credentials: false
18
+ - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
19
+ with:
20
+ enable-cache: false
21
+ - name: Check that the tag matches the version
22
+ run: test "${GITHUB_REF_NAME#v}" = "$(uv version --short)"
23
+ - name: Run tests
24
+ run: uv run --python 3.13 --group test pytest
25
+ - name: Build
26
+ run: uv build
27
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
28
+ with:
29
+ name: dist
30
+ path: dist/
31
+
32
+ publish:
33
+ needs: build
34
+ runs-on: ubuntu-latest
35
+ environment:
36
+ name: pypi
37
+ url: https://pypi.org/project/msgspec-fastapi/
38
+ permissions:
39
+ id-token: write
40
+ steps:
41
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
42
+ with:
43
+ name: dist
44
+ path: dist/
45
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ dist/
6
+ uv.lock
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Aleksandr Tseluiko
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
17
+ contributors may be used to endorse or promote products derived from
18
+ this software 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,98 @@
1
+ Metadata-Version: 2.5
2
+ Name: msgspec-fastapi
3
+ Version: 0.0.1
4
+ Summary: Experimental FastAPI router that decodes request bodies and encodes responses with msgspec
5
+ Project-URL: Source, https://github.com/Siyet/msgspec-fastapi
6
+ Project-URL: Issues, https://github.com/Siyet/msgspec-fastapi/issues
7
+ Author: Aleksandr Tseluiko
8
+ License-Expression: BSD-3-Clause
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 2 - Pre-Alpha
11
+ Classifier: Framework :: FastAPI
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: fastapi<0.144,>=0.142.2
15
+ Requires-Dist: msgspec>=0.22.0
16
+ Description-Content-Type: text/markdown
17
+
18
+ # msgspec-fastapi
19
+
20
+ An experimental FastAPI router that uses msgspec for JSON request bodies and responses.
21
+
22
+ This is a prototype. Its API may change, and it relies on private FastAPI internals, so
23
+ it is pinned to FastAPI 0.142.2 to 0.143.x. It is a personal project, not part of
24
+ msgspec; feedback is welcome in the issues.
25
+
26
+ ## Installation
27
+
28
+ ```sh
29
+ pip install msgspec-fastapi
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```python
35
+ import msgspec
36
+ from fastapi import FastAPI
37
+ from msgspec_fastapi import MsgspecRouter, install_openapi
38
+
39
+
40
+ class City(msgspec.Struct):
41
+ name: str
42
+ population: int
43
+
44
+
45
+ router = MsgspecRouter()
46
+
47
+
48
+ @router.post('/cities')
49
+ async def create_city(city: City) -> City:
50
+ return city
51
+
52
+
53
+ app = FastAPI()
54
+ app.include_router(router)
55
+ install_openapi(app, router)
56
+ ```
57
+
58
+ ## What it does
59
+
60
+ - A msgspec body parameter (a Struct or another type msgspec can decode) is decoded from
61
+ the raw request bytes by a `msgspec.json.Decoder` built once at route registration.
62
+ A non-JSON content type returns 415, malformed JSON returns 400, and a missing or
63
+ invalid body returns 422. Bodies are decoded in msgspec strict mode: for example, the
64
+ JSON string `"1"` is not accepted for an `int` field, unlike FastAPI with Pydantic.
65
+ - A msgspec response type, from the return annotation or `response_model`, is encoded
66
+ with msgspec. An instance of exactly the declared Struct class is encoded without
67
+ revalidation, as FastAPI does for a Pydantic `response_model`; other values are
68
+ validated against the declared type. Nested values and type parameters of a trusted
69
+ instance are not checked.
70
+ - Responses of routes without a return annotation or `response_model` are also encoded
71
+ with msgspec, so the output can differ from FastAPI's `jsonable_encoder` (for example,
72
+ `bytes` are base64-encoded and `Decimal` is encoded as a string).
73
+ - Path, query, header and cookie parameters keep FastAPI extraction, aliases and
74
+ defaults, but are converted with `msgspec.convert(strict=False)` instead of Pydantic,
75
+ so coercion differs for some inputs (for example, `" 1.5"` is rejected for `float`
76
+ and `"nan"` is accepted for `Decimal`). Parameter types must be supported by msgspec;
77
+ Pydantic-specific types such as `AnyUrl` raise `TypeError` at route registration.
78
+ - `install_openapi` adds the msgspec JSON schemas to the OpenAPI document. It is required
79
+ for a valid document: call it once after including the routers, passing every
80
+ `MsgspecRouter` that is not included directly in another `MsgspecRouter`.
81
+ - A Pydantic body or response model is left to FastAPI; the route parameters are still
82
+ validated with msgspec.
83
+
84
+ ## Limitations
85
+
86
+ - Only one msgspec body parameter per route, and it cannot be combined with other
87
+ FastAPI body fields. Only JSON bodies are supported: `application/json`,
88
+ `application/*+json`, or a request without a content type.
89
+ - Structs, alone or inside containers, are not supported as path, query, header or
90
+ cookie parameters.
91
+ - `response_model_include`, `response_model_exclude`, `response_model_exclude_*` and
92
+ `response_model_by_alias=False` are rejected for typed msgspec responses.
93
+
94
+ ## Development
95
+
96
+ ```sh
97
+ uv run --group test pytest
98
+ ```
@@ -0,0 +1,81 @@
1
+ # msgspec-fastapi
2
+
3
+ An experimental FastAPI router that uses msgspec for JSON request bodies and responses.
4
+
5
+ This is a prototype. Its API may change, and it relies on private FastAPI internals, so
6
+ it is pinned to FastAPI 0.142.2 to 0.143.x. It is a personal project, not part of
7
+ msgspec; feedback is welcome in the issues.
8
+
9
+ ## Installation
10
+
11
+ ```sh
12
+ pip install msgspec-fastapi
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```python
18
+ import msgspec
19
+ from fastapi import FastAPI
20
+ from msgspec_fastapi import MsgspecRouter, install_openapi
21
+
22
+
23
+ class City(msgspec.Struct):
24
+ name: str
25
+ population: int
26
+
27
+
28
+ router = MsgspecRouter()
29
+
30
+
31
+ @router.post('/cities')
32
+ async def create_city(city: City) -> City:
33
+ return city
34
+
35
+
36
+ app = FastAPI()
37
+ app.include_router(router)
38
+ install_openapi(app, router)
39
+ ```
40
+
41
+ ## What it does
42
+
43
+ - A msgspec body parameter (a Struct or another type msgspec can decode) is decoded from
44
+ the raw request bytes by a `msgspec.json.Decoder` built once at route registration.
45
+ A non-JSON content type returns 415, malformed JSON returns 400, and a missing or
46
+ invalid body returns 422. Bodies are decoded in msgspec strict mode: for example, the
47
+ JSON string `"1"` is not accepted for an `int` field, unlike FastAPI with Pydantic.
48
+ - A msgspec response type, from the return annotation or `response_model`, is encoded
49
+ with msgspec. An instance of exactly the declared Struct class is encoded without
50
+ revalidation, as FastAPI does for a Pydantic `response_model`; other values are
51
+ validated against the declared type. Nested values and type parameters of a trusted
52
+ instance are not checked.
53
+ - Responses of routes without a return annotation or `response_model` are also encoded
54
+ with msgspec, so the output can differ from FastAPI's `jsonable_encoder` (for example,
55
+ `bytes` are base64-encoded and `Decimal` is encoded as a string).
56
+ - Path, query, header and cookie parameters keep FastAPI extraction, aliases and
57
+ defaults, but are converted with `msgspec.convert(strict=False)` instead of Pydantic,
58
+ so coercion differs for some inputs (for example, `" 1.5"` is rejected for `float`
59
+ and `"nan"` is accepted for `Decimal`). Parameter types must be supported by msgspec;
60
+ Pydantic-specific types such as `AnyUrl` raise `TypeError` at route registration.
61
+ - `install_openapi` adds the msgspec JSON schemas to the OpenAPI document. It is required
62
+ for a valid document: call it once after including the routers, passing every
63
+ `MsgspecRouter` that is not included directly in another `MsgspecRouter`.
64
+ - A Pydantic body or response model is left to FastAPI; the route parameters are still
65
+ validated with msgspec.
66
+
67
+ ## Limitations
68
+
69
+ - Only one msgspec body parameter per route, and it cannot be combined with other
70
+ FastAPI body fields. Only JSON bodies are supported: `application/json`,
71
+ `application/*+json`, or a request without a content type.
72
+ - Structs, alone or inside containers, are not supported as path, query, header or
73
+ cookie parameters.
74
+ - `response_model_include`, `response_model_exclude`, `response_model_exclude_*` and
75
+ `response_model_by_alias=False` are rejected for typed msgspec responses.
76
+
77
+ ## Development
78
+
79
+ ```sh
80
+ uv run --group test pytest
81
+ ```
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "msgspec-fastapi"
7
+ version = "0.0.1"
8
+ description = "Experimental FastAPI router that decodes request bodies and encodes responses with msgspec"
9
+ readme = "README.md"
10
+ license = "BSD-3-Clause"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.11"
13
+ authors = [{ name = "Aleksandr Tseluiko" }]
14
+ classifiers = [
15
+ "Development Status :: 2 - Pre-Alpha",
16
+ "Framework :: FastAPI",
17
+ "Programming Language :: Python :: 3",
18
+ ]
19
+ dependencies = [
20
+ "fastapi>=0.142.2,<0.144",
21
+ "msgspec>=0.22.0",
22
+ ]
23
+
24
+ [project.urls]
25
+ Source = "https://github.com/Siyet/msgspec-fastapi"
26
+ Issues = "https://github.com/Siyet/msgspec-fastapi/issues"
27
+
28
+ [dependency-groups]
29
+ test = ["pytest", "httpx", "httpx2"]
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/msgspec_fastapi"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ from .responses import MsgspecJSONResponse
2
+ from .router import MsgspecRouter, install_openapi
3
+
4
+ __all__ = ['MsgspecJSONResponse', 'MsgspecRouter', 'install_openapi']
@@ -0,0 +1,10 @@
1
+ from typing import Any
2
+ import msgspec
3
+ from starlette.responses import JSONResponse
4
+
5
+ _JSON_ENCODER = msgspec.json.Encoder()
6
+
7
+ class MsgspecJSONResponse(JSONResponse):
8
+ """Encode JSON with msgspec; use MsgspecRouter for output validation."""
9
+ def render(self, content: Any) -> bytes:
10
+ return _JSON_ENCODER.encode(content)
@@ -0,0 +1,406 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from copy import deepcopy
5
+ from functools import partial, wraps
6
+ from hashlib import sha256
7
+ from typing import Annotated, get_args, get_origin, get_type_hints
8
+
9
+ import msgspec
10
+ from pydantic import BaseModel
11
+ from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
12
+ from fastapi.datastructures import DefaultPlaceholder
13
+ from fastapi.exceptions import RequestValidationError, ResponseValidationError
14
+ from fastapi.openapi.utils import validation_error_definition, validation_error_response_definition
15
+ from fastapi.params import Body, Depends, Param
16
+ from fastapi.routing import APIRoute, _effective_route_context_var, iter_route_contexts
17
+ from starlette.concurrency import run_in_threadpool
18
+
19
+ from .responses import MsgspecJSONResponse
20
+ from .validation import base_type, constrained_type, prepare_parameters, structured_type, uses_pydantic
21
+
22
+
23
+ def _untyped_encode_hook(value):
24
+ if isinstance(value, BaseModel):
25
+ return value.model_dump(mode='json', by_alias=True)
26
+ raise TypeError(f'Encoding objects of type {type(value).__name__} is unsupported')
27
+
28
+
29
+ def _contains_struct(annotation):
30
+ value = base_type(annotation)
31
+ return (isinstance(value, type) and issubclass(value, msgspec.Struct)) or any(
32
+ _contains_struct(arg) for arg in get_args(value)
33
+ )
34
+
35
+
36
+ def _body_type(annotation):
37
+ value = base_type(annotation)
38
+ if structured_type(value):
39
+ return True
40
+ if get_origin(value) in (list, dict, tuple, set, frozenset):
41
+ return True
42
+ return any(_body_type(arg) for arg in get_args(value))
43
+
44
+
45
+ def _metadata(parameter, annotation):
46
+ items = list(get_args(annotation)[1:]) if get_origin(annotation) is Annotated else []
47
+ return [*items, parameter.default]
48
+
49
+
50
+ def _merge_components(target, incoming):
51
+ for name, schema in incoming.items():
52
+ if name in target and target[name] != schema:
53
+ raise ValueError(f'Conflicting OpenAPI component: {name}')
54
+ target[name] = deepcopy(schema)
55
+
56
+
57
+ def _schema(annotation, components):
58
+ (schema,), definitions = msgspec.json.schema_components(
59
+ [annotation], ref_template='#/components/schemas/{name}'
60
+ )
61
+ _merge_components(components, definitions)
62
+ return schema
63
+
64
+
65
+ def _json_type(annotation):
66
+ if annotation in (inspect.Signature.empty, None) or uses_pydantic(annotation):
67
+ return False
68
+ value = base_type(annotation)
69
+ if isinstance(value, type) and issubclass(value, Response):
70
+ return False
71
+ try:
72
+ msgspec.json.Decoder(type=annotation)
73
+ msgspec.json.schema(annotation)
74
+ except (TypeError, ValueError):
75
+ return False
76
+ return True
77
+
78
+
79
+ def _hints(endpoint):
80
+ target = endpoint
81
+ while isinstance(target, partial):
82
+ target = target.func
83
+ if not inspect.isfunction(target) and not inspect.ismethod(target):
84
+ target = target.__call__
85
+ return get_type_hints(target, include_extras=True)
86
+
87
+
88
+ def _parse_body(request, raw, config):
89
+ decoder, default, embedded = config
90
+ if not raw:
91
+ if default is inspect.Signature.empty:
92
+ raise RequestValidationError([{'loc': ('body',), 'msg': 'Field required', 'type': 'missing'}])
93
+ return deepcopy(default)
94
+ media_type = request.headers.get('content-type', '').split(';')[0].strip().lower()
95
+ if media_type and media_type != 'application/json' and not (
96
+ media_type.startswith('application/') and media_type.endswith('+json')
97
+ ):
98
+ raise HTTPException(415, 'Expected a JSON request body')
99
+ try:
100
+ value = decoder.decode(raw)
101
+ except msgspec.ValidationError as exc:
102
+ # Typed decoding can stop on a field error before discovering invalid syntax.
103
+ try:
104
+ msgspec.json.decode(raw)
105
+ except (msgspec.DecodeError, UnicodeError) as syntax:
106
+ raise HTTPException(400, f'Malformed JSON body: {syntax}') from syntax
107
+ raise RequestValidationError([{'loc': ('body',), 'msg': str(exc), 'type': 'value_error.msgspec'}]) from exc
108
+ except (msgspec.DecodeError, UnicodeError) as exc:
109
+ raise HTTPException(400, f'Malformed JSON body: {exc}') from exc
110
+ return value.value if embedded else value
111
+
112
+
113
+ class MsgspecRoute(APIRoute):
114
+ def get_route_handler(self):
115
+ context = _effective_route_context_var.get()
116
+ route = context if context is not None and context.original_route is self else self
117
+ prepare_parameters(route.dependant)
118
+ handler = super().get_route_handler()
119
+ config = getattr(self.endpoint, '_msgspec_body_config', None)
120
+ if config is None:
121
+ return handler
122
+ if route.body_field is not None:
123
+ raise TypeError('Other FastAPI body fields cannot be combined with a msgspec body')
124
+
125
+ async def handle(request):
126
+ # Invalid JSON must be rejected before dependency side effects.
127
+ request.scope['_msgspec_body_value'] = _parse_body(request, await request.body(), config)
128
+ return await handler(request)
129
+ return handle
130
+
131
+
132
+ def install_openapi(app: FastAPI, *routers: 'MsgspecRouter') -> None:
133
+ """Install once after including routers, before serving the application."""
134
+ if not routers:
135
+ raise TypeError('Pass the MsgspecRouter instances included in the app')
136
+ if getattr(app, '_msgspec_openapi_installed', False):
137
+ raise RuntimeError('msgspec OpenAPI is already installed')
138
+ # FastAPI builds included contexts lazily; validate them before serving.
139
+ list(iter_route_contexts(app.routes))
140
+ original = app.openapi
141
+ app.openapi_schema = None
142
+
143
+ def openapi():
144
+ document = original()
145
+ components = document.setdefault('components', {}).setdefault('schemas', {})
146
+ for router in routers:
147
+ _merge_components(components, router.msgspec_components)
148
+ for route in iter_route_contexts(app.routes):
149
+ original_route = getattr(route, 'original_route', route)
150
+ if not isinstance(original_route, MsgspecRoute):
151
+ continue
152
+ fields = []
153
+ def collect(dependant):
154
+ for group in ('path_params', 'query_params', 'header_params', 'cookie_params'):
155
+ fields.extend(getattr(dependant, group))
156
+ for dependency in dependant.dependencies:
157
+ collect(dependency)
158
+ collect(route.dependant)
159
+ for method in route.methods:
160
+ operation = document.get('paths', {}).get(route.path_format, {}).get(method.lower(), {})
161
+ for parameter in operation.get('parameters', []):
162
+ for field in fields:
163
+ if (getattr(field, '_msgspec_parameter', False)
164
+ and parameter['in'] == field.field_info.in_.value
165
+ and parameter['name'] == field.alias):
166
+ schema = deepcopy(field._msgspec_schema)
167
+ for key in ('title', 'description', 'default'):
168
+ if key in parameter.get('schema', {}):
169
+ schema[key] = parameter['schema'][key]
170
+ parameter['schema'] = schema
171
+ return document
172
+
173
+ app.openapi = openapi
174
+ app._msgspec_openapi_installed = True
175
+
176
+
177
+ class MsgspecRouter(APIRouter):
178
+ """Typed msgspec JSON bodies and validated responses for ordinary CRUD routes."""
179
+
180
+ def __init__(self, *args, **kwargs):
181
+ kwargs.setdefault('route_class', MsgspecRoute)
182
+ kwargs.setdefault('default_response_class', MsgspecJSONResponse)
183
+ if not issubclass(kwargs['route_class'], MsgspecRoute):
184
+ raise TypeError('route_class must extend MsgspecRoute')
185
+ super().__init__(*args, **kwargs)
186
+ self.msgspec_components = {}
187
+
188
+ def include_router(self, router, *args, **kwargs):
189
+ if isinstance(router, MsgspecRouter):
190
+ _merge_components(self.msgspec_components, router.msgspec_components)
191
+ return super().include_router(router, *args, **kwargs)
192
+
193
+ def add_api_route(self, path, endpoint, **kwargs):
194
+ override = kwargs.get('route_class_override')
195
+ if override is not None and not issubclass(override, MsgspecRoute):
196
+ raise TypeError('route_class_override must extend MsgspecRoute')
197
+ # Included routes already have a prepared signature and response metadata.
198
+ if getattr(endpoint, '_msgspec_prepared', False):
199
+ return super().add_api_route(path, endpoint, **kwargs)
200
+ signature = inspect.signature(endpoint)
201
+ hints = _hints(endpoint)
202
+ parameters = [p.replace(annotation=hints.get(p.name, p.annotation)) for p in signature.parameters.values()]
203
+ return_type = hints.get('return', signature.return_annotation)
204
+ explicit = kwargs.get('response_model', inspect.Signature.empty)
205
+ inferred = explicit is inspect.Signature.empty or isinstance(explicit, DefaultPlaceholder)
206
+ output_type = return_type if inferred else explicit
207
+ validates_output = _json_type(output_type)
208
+ if validates_output:
209
+ output_type, output_strict = constrained_type(output_type)
210
+ if output_strict is False:
211
+ raise TypeError('JSON msgspec responses use strict validation')
212
+ output_decoder = msgspec.json.Decoder(type=output_type) if validates_output else None
213
+ # Like FastAPI with a Pydantic response_model at the top level: an instance of exactly
214
+ # the declared Struct class is trusted and encoded once; other top-level values,
215
+ # including subclass instances, are validated against the schema. Unlike Pydantic,
216
+ # values nested in a trusted instance are encoded as they are (a nested subclass keeps
217
+ # its extra fields, and the type parameters of a generic Struct are not checked).
218
+ output_class = (get_origin(base_type(output_type)) or base_type(output_type)) if validates_output else None
219
+ trusts_instances = isinstance(output_class, type) and issubclass(output_class, msgspec.Struct)
220
+ output_encoder = msgspec.json.Encoder() if validates_output else None
221
+ response_class = kwargs.get('response_class')
222
+ if response_class is None or isinstance(response_class, DefaultPlaceholder):
223
+ response_class = self.default_response_class
224
+ if isinstance(response_class, DefaultPlaceholder):
225
+ response_class = response_class.value
226
+ serializes_untyped_output = (
227
+ output_type in (inspect.Signature.empty, None)
228
+ and not uses_pydantic(return_type)
229
+ and response_class is MsgspecJSONResponse
230
+ )
231
+ handles_output = validates_output or serializes_untyped_output or (
232
+ explicit is None and _contains_struct(return_type)
233
+ )
234
+ untyped_encoder = (
235
+ msgspec.json.Encoder(enc_hook=_untyped_encode_hook)
236
+ if serializes_untyped_output else None
237
+ )
238
+ body = None
239
+ body_meta = None
240
+ for parameter in parameters:
241
+ metadata = _metadata(parameter, parameter.annotation)
242
+ if any(isinstance(x, Depends) for x in metadata):
243
+ continue
244
+ is_body = _contains_struct(parameter.annotation) or any(isinstance(x, Body) for x in metadata)
245
+ if _body_type(parameter.annotation) and not any(isinstance(x, Param) for x in metadata):
246
+ is_body = True
247
+ if is_body and _json_type(parameter.annotation):
248
+ if any(isinstance(x, Param) for x in metadata):
249
+ raise TypeError('msgspec Struct query/path/header/cookie parameters are not supported')
250
+ if body is not None:
251
+ raise TypeError('Only one msgspec body parameter is supported; other body fields cannot be combined')
252
+ body = parameter
253
+ body_meta = next((x for x in metadata if isinstance(x, Body)), None)
254
+
255
+ if body is None and not handles_output:
256
+ return super().add_api_route(path, endpoint, **kwargs)
257
+ if any(p.kind in (p.VAR_KEYWORD, p.VAR_POSITIONAL) for p in parameters):
258
+ raise TypeError('Variadic msgspec endpoints are not supported')
259
+ if handles_output:
260
+ if validates_output:
261
+ for option in ('response_model_include', 'response_model_exclude',
262
+ 'response_model_exclude_unset', 'response_model_exclude_defaults',
263
+ 'response_model_exclude_none'):
264
+ if kwargs.get(option):
265
+ raise TypeError(f'{option} is not supported for msgspec responses')
266
+ if kwargs.get('response_model_by_alias', True) is False:
267
+ raise TypeError('msgspec responses use encoded field names')
268
+ if validates_output and kwargs.get('status_code') in (204, 304):
269
+ raise TypeError('A typed msgspec response requires a response body')
270
+ response_class = kwargs.get('response_class')
271
+ if response_class is not None and not isinstance(response_class, DefaultPlaceholder) and response_class is not MsgspecJSONResponse:
272
+ raise TypeError('Custom response_class is not supported for typed msgspec responses')
273
+ kwargs['response_model'] = None
274
+ kwargs['response_class'] = MsgspecJSONResponse
275
+
276
+ components = {}
277
+ responses = deepcopy(kwargs.get('responses') or {})
278
+ openapi_extra = deepcopy(kwargs.get('openapi_extra') or {})
279
+ default = inspect.Signature.empty
280
+ decoder = None
281
+ embedded = False
282
+ if body is not None:
283
+ metadata = [x for x in get_args(body.annotation)[1:] if not isinstance(x, Body)] if get_origin(body.annotation) is Annotated else []
284
+ body_type, body_strict = constrained_type(base_type(body.annotation), metadata)
285
+ if body_strict is False:
286
+ raise TypeError('JSON msgspec bodies use strict validation')
287
+ if body_meta is not None:
288
+ if body_meta.media_type != 'application/json':
289
+ raise TypeError('Only application/json msgspec bodies are supported')
290
+ body_type, body_strict = constrained_type(body_type, body_meta.metadata)
291
+ if body_strict is False:
292
+ raise TypeError('JSON msgspec bodies use strict validation')
293
+ embedded = body_meta.embed is True
294
+ default = body_meta.default
295
+ from pydantic_core import PydanticUndefined
296
+ if default is PydanticUndefined or default is Ellipsis:
297
+ default = inspect.Signature.empty
298
+ if not isinstance(body.default, Body) and body.default is not inspect.Signature.empty:
299
+ default = body.default
300
+ if body_meta.default_factory is not None:
301
+ raise TypeError('Body default_factory is not supported')
302
+ else:
303
+ default = body.default
304
+ decode_type = body_type
305
+ if embedded:
306
+ # One explicit JSON envelope, preserving a possible Body alias.
307
+ field = ('value', body_type) if default is inspect.Signature.empty else (
308
+ 'value', body_type, msgspec.field(default_factory=lambda: deepcopy(default))
309
+ )
310
+ identity = {'schema': msgspec.json.schema(body_type), 'alias': body_meta.alias or body.name,
311
+ 'required': default is inspect.Signature.empty}
312
+ if default is not inspect.Signature.empty:
313
+ identity['default'] = default
314
+ name = 'MsgspecBody_' + sha256(msgspec.json.encode(identity)).hexdigest()[:16]
315
+ decode_type = msgspec.defstruct(name, [field],
316
+ rename={'value': body_meta.alias or body.name})
317
+ decoder = msgspec.json.Decoder(type=decode_type)
318
+ body_schema = _schema(decode_type, components)
319
+ openapi_extra['requestBody'] = {'required': default is inspect.Signature.empty,
320
+ 'content': {'application/json': {'schema': body_schema}}}
321
+ responses.setdefault(400, {'description': 'Malformed JSON'})
322
+ responses.setdefault(422, {'description': 'Validation Error', 'content': {
323
+ 'application/json': {'schema': {'$ref': '#/components/schemas/HTTPValidationError'}}}})
324
+ _merge_components(components, {'HTTPValidationError': validation_error_response_definition,
325
+ 'ValidationError': validation_error_definition})
326
+ if validates_output:
327
+ code = kwargs.get('status_code') or 200
328
+ key = str(code) if str(code) in responses else code
329
+ result_meta = responses.setdefault(key, {})
330
+ result_meta.setdefault('description', kwargs.get('response_description', 'Successful Response'))
331
+ result_meta.setdefault('content', {})['application/json'] = {'schema': _schema(output_type, components)}
332
+ previous_components = deepcopy(self.msgspec_components)
333
+ _merge_components(self.msgspec_components, components)
334
+ kwargs['responses'] = responses
335
+ kwargs['openapi_extra'] = openapi_extra
336
+
337
+ remaining = [p for p in parameters if p is not body]
338
+ def injected_name(type_, fallback):
339
+ for p in remaining:
340
+ annotation = base_type(p.annotation)
341
+ if isinstance(annotation, type) and issubclass(annotation, type_):
342
+ return p.name, False
343
+ names = {p.name for p in parameters}
344
+ while fallback in names:
345
+ fallback += '_'
346
+ remaining.append(inspect.Parameter(fallback, kind=inspect.Parameter.KEYWORD_ONLY, annotation=type_))
347
+ return fallback, True
348
+ request_name, remove_request = injected_name(Request, '__msgspec_request') if body else (None, False)
349
+ response_name, remove_response = injected_name(Response, '__msgspec_response') if handles_output else (None, False)
350
+
351
+ is_async = inspect.iscoroutinefunction(endpoint) or inspect.iscoroutinefunction(getattr(endpoint, '__call__', None))
352
+ @wraps(endpoint)
353
+ async def wrapper(**values):
354
+ sub_response = values.get(response_name)
355
+ if remove_response:
356
+ values.pop(response_name)
357
+ if body is not None:
358
+ request = values[request_name]
359
+ if remove_request:
360
+ values.pop(request_name)
361
+ value = request.scope['_msgspec_body_value']
362
+ values[body.name] = value
363
+ if is_async:
364
+ result = await endpoint(**values)
365
+ else:
366
+ result = await run_in_threadpool(endpoint, **values)
367
+ if inspect.isawaitable(result):
368
+ result = await result
369
+ if isinstance(result, Response):
370
+ return result
371
+ if not handles_output:
372
+ return msgspec.to_builtins(result) if _contains_struct(return_type) else result
373
+ if validates_output and not (trusts_instances and type(result) is output_class):
374
+ try:
375
+ # Typed decoding validates fields and Meta, even for existing Structs.
376
+ result = output_decoder.decode(output_encoder.encode(result))
377
+ except (TypeError, ValueError, OverflowError) as exc:
378
+ raise ResponseValidationError([{'loc': ('response',), 'msg': str(exc), 'type': 'value_error.msgspec'}]) from exc
379
+ status_code = sub_response.status_code or kwargs.get('status_code') or 200
380
+ if untyped_encoder is not None:
381
+ response = Response(untyped_encoder.encode(result), status_code=status_code,
382
+ media_type='application/json')
383
+ else:
384
+ response = MsgspecJSONResponse(result, status_code=status_code)
385
+ if response.status_code < 200 or response.status_code in (204, 205, 304):
386
+ response.body = b''
387
+ if 'content-length' in response.headers:
388
+ del response.headers['content-length']
389
+ response.raw_headers.extend(sub_response.raw_headers)
390
+ return response
391
+
392
+ wrapper.__signature__ = signature.replace(parameters=remaining, return_annotation=inspect.Signature.empty if handles_output else return_type)
393
+ wrapper._msgspec_prepared = True
394
+ if body is not None:
395
+ wrapper._msgspec_body_config = (decoder, default, embedded)
396
+ route_count = len(self.routes)
397
+ try:
398
+ result = super().add_api_route(path, wrapper, **kwargs)
399
+ if body is not None and self.routes[-1].body_field is not None:
400
+ raise TypeError('Other FastAPI body fields cannot be combined with a msgspec body')
401
+ return result
402
+ except Exception:
403
+ del self.routes[route_count:]
404
+ self.msgspec_components = previous_components
405
+ raise
406
+
@@ -0,0 +1,239 @@
1
+ """Route-local validation for HTTP parameters; no global FastAPI changes."""
2
+ import re
3
+
4
+ from typing import Annotated, Any, Literal, Union, get_args, get_origin, get_type_hints
5
+ from dataclasses import is_dataclass
6
+ from typing_extensions import is_typeddict
7
+ from types import UnionType
8
+
9
+ import annotated_types
10
+ import msgspec
11
+ from pydantic import BaseModel
12
+ from pydantic.fields import FieldInfo
13
+
14
+
15
+ def base_type(annotation):
16
+ return get_args(annotation)[0] if get_origin(annotation) is Annotated else annotation
17
+
18
+
19
+ def structured_type(annotation):
20
+ value = get_origin(base_type(annotation)) or base_type(annotation)
21
+ return isinstance(value, type) and (
22
+ issubclass(value, msgspec.Struct) or is_typeddict(value) or is_dataclass(value)
23
+ or (issubclass(value, tuple) and hasattr(value, '_fields'))
24
+ )
25
+
26
+
27
+ def uses_pydantic(annotation):
28
+ value = base_type(annotation)
29
+ return (isinstance(value, type) and issubclass(value, BaseModel)) or any(
30
+ uses_pydantic(item) for item in get_args(value)
31
+ )
32
+
33
+
34
+ def _check_structured_metadata(annotation, seen=None):
35
+ if seen is None:
36
+ seen = set()
37
+ if get_origin(annotation) is Annotated:
38
+ annotation, *metadata = get_args(annotation)
39
+ if any(not isinstance(item, msgspec.Meta) for item in metadata):
40
+ raise TypeError('Use msgspec.Meta inside structured model fields')
41
+ origin = get_origin(annotation)
42
+ value = origin or annotation
43
+ structured = structured_type(annotation)
44
+ if structured and value not in seen:
45
+ seen.add(value)
46
+ for field_type in get_type_hints(value, include_extras=True).values():
47
+ _check_structured_metadata(field_type, seen)
48
+ for arg in get_args(annotation):
49
+ _check_structured_metadata(arg, seen)
50
+
51
+
52
+ def constrained_type(annotation, metadata=()):
53
+ """Translate supported FastAPI constraints, rejecting unhandled validation."""
54
+ if get_origin(annotation) is Annotated:
55
+ annotation, *extras = get_args(annotation)
56
+ metadata = (*extras, *metadata)
57
+ origin, args = get_origin(annotation), get_args(annotation)
58
+ if structured_type(annotation):
59
+ _check_structured_metadata(annotation)
60
+ if args and origin is not Literal:
61
+ converted = []
62
+ for arg in args:
63
+ if arg is Ellipsis:
64
+ converted.append(arg)
65
+ continue
66
+ target, nested_strict = constrained_type(arg)
67
+ if nested_strict is not None:
68
+ raise TypeError('Nested strict constraints are not supported; use a strict top-level parameter')
69
+ converted.append(target)
70
+ converted = tuple(converted)
71
+ if hasattr(annotation, 'copy_with'):
72
+ annotation = annotation.copy_with(converted)
73
+ elif origin in (Union, UnionType):
74
+ annotation = Union[converted]
75
+ else:
76
+ annotation = origin[converted[0] if len(converted) == 1 else converted]
77
+ expanded = []
78
+ for item in metadata:
79
+ if isinstance(item, FieldInfo):
80
+ expanded.extend(item.metadata)
81
+ else:
82
+ expanded.append(item)
83
+ metadata = expanded
84
+ constraints = {}
85
+ metas = []
86
+ strict = None
87
+ for item in metadata:
88
+ if isinstance(item, msgspec.Meta):
89
+ metas.append(item)
90
+ elif isinstance(item, (annotated_types.Gt, annotated_types.Ge, annotated_types.Lt,
91
+ annotated_types.Le, annotated_types.MultipleOf,
92
+ annotated_types.MinLen, annotated_types.MaxLen)):
93
+ name = {'MinLen': 'min_length', 'MaxLen': 'max_length'}.get(type(item).__name__,
94
+ {'MultipleOf': 'multiple_of'}.get(type(item).__name__, type(item).__name__.lower()))
95
+ constraints[name] = getattr(item, name)
96
+ elif type(item).__module__.startswith('pydantic') and type(item).__name__ == 'Strict':
97
+ strict = item.strict
98
+ elif type(item).__module__.startswith('pydantic') and type(item).__name__ == '_PydanticGeneralMetadata':
99
+ for name, value in vars(item).items():
100
+ if name not in ('pattern',):
101
+ raise TypeError(f'Unsupported msgspec parameter constraint: {name}')
102
+ constraints[name] = value
103
+ else:
104
+ raise TypeError(f'Unsupported msgspec parameter metadata: {type(item).__name__}')
105
+ if constraints:
106
+ metas.append(msgspec.Meta(**constraints))
107
+ if metas:
108
+ # FastAPI constraints on nullable scalars apply to the non-null branch.
109
+ args = get_args(annotation)
110
+ if get_origin(annotation) in (Union, UnionType) and type(None) in args and len(args) == 2:
111
+ inner = next(arg for arg in args if arg is not type(None))
112
+ target = Union[Annotated[inner, *metas], type(None)]
113
+ else:
114
+ target = Annotated[annotation, *metas]
115
+ else:
116
+ target = annotation
117
+ return target, strict
118
+
119
+
120
+
121
+ # Unicode White_Space used by Rust str::trim, excluding Python's extra C0 separators.
122
+ _HTTP_WHITESPACE = '\t\n\v\f\r \x85\xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000'
123
+ _HTTP_INTEGER = re.compile(r'-?(?:0|[1-9][0-9]*)\Z')
124
+
125
+
126
+ def _http_int(value):
127
+ """Match pydantic-core 2.46.5 str_as_int/clean_int_str on HTTP strings."""
128
+ if not isinstance(value, str):
129
+ return value
130
+ # Preserve the direct parser's size/error behavior before its cleanup fallback.
131
+ if _HTTP_INTEGER.fullmatch(value):
132
+ return int(value)
133
+ number = value.strip(_HTTP_WHITESPACE)
134
+ if number.startswith('+'):
135
+ number = number[1:]
136
+ if number.startswith('-'):
137
+ raise ValueError('Invalid integer parameter')
138
+ negative = number.startswith('-')
139
+ if negative:
140
+ number = number[1:]
141
+ if number.startswith(('-', '+')):
142
+ raise ValueError('Invalid integer parameter')
143
+ if not number or number[0] not in '0123456789-':
144
+ raise ValueError('Invalid integer parameter')
145
+ if number[0] == '0':
146
+ for offset in range(1, len(number)):
147
+ char = number[offset]
148
+ if char in '0_':
149
+ continue
150
+ if char in '123456789-':
151
+ number = number[offset:]
152
+ break
153
+ if char == '.':
154
+ number = number[offset - 1:]
155
+ break
156
+ raise ValueError('Invalid integer parameter')
157
+ else:
158
+ number = number[-1:]
159
+ if '.' in number:
160
+ whole, decimal = number.split('.', 1)
161
+ if decimal and all(char == '0' for char in decimal):
162
+ number = whole
163
+ if ('_' in number and not number.startswith('_') and not number.endswith('_')
164
+ and '__' not in number):
165
+ number = number.replace('_', '')
166
+ if negative:
167
+ number = '-' + number
168
+ if not _HTTP_INTEGER.fullmatch(number):
169
+ raise ValueError('Invalid integer parameter')
170
+ return int(number)
171
+
172
+ def _http_normalizer(annotation):
173
+ """Compile supported transport coercions once at route registration."""
174
+ annotation = base_type(annotation)
175
+ origin, args = get_origin(annotation), get_args(annotation)
176
+ if origin in (Union, UnionType) and type(None) in args and len(args) == 2:
177
+ return _http_normalizer(next(arg for arg in args if arg is not type(None)))
178
+ if origin in (list, set, frozenset, tuple):
179
+ if origin is tuple and args and args[-1] is not Ellipsis:
180
+ children = tuple(_http_normalizer(arg) for arg in args)
181
+ if not any(children):
182
+ return None
183
+ def normalize_tuple(value):
184
+ return [children[i](item) if i < len(children) and children[i] else item
185
+ for i, item in enumerate(value)] if isinstance(value, (list, tuple)) else value
186
+ return normalize_tuple
187
+ child = _http_normalizer(args[0]) if args else None
188
+ if child is None:
189
+ return None
190
+ def normalize_sequence(value):
191
+ return [child(item) for item in value] if isinstance(value, (list, tuple)) else value
192
+ return normalize_sequence
193
+ if annotation is int:
194
+ return _http_int
195
+ if annotation is bool:
196
+ def normalize_bool(value):
197
+ if isinstance(value, str):
198
+ lowered = value.lower()
199
+ if lowered in ('yes', 'on', 'y', 't'):
200
+ return True
201
+ if lowered in ('no', 'off', 'n', 'f'):
202
+ return False
203
+ return value
204
+ return normalize_bool
205
+ if annotation is bytes:
206
+ return lambda value: value.encode('utf-8') if isinstance(value, str) else value
207
+ return None
208
+
209
+
210
+ def _parameter_validator(target, strict):
211
+ normalizer = None if strict else _http_normalizer(target)
212
+ convert = msgspec.convert
213
+ def validate(value: Any, values=None, *, loc=()):
214
+ try:
215
+ if normalizer is not None:
216
+ value = normalizer(value)
217
+ return convert(value, type=target, strict=strict), []
218
+ except (ValueError, UnicodeError, OverflowError) as exc:
219
+ return None, [{'loc': loc, 'msg': str(exc), 'type': 'value_error.msgspec'}]
220
+ return validate
221
+
222
+
223
+ def prepare_parameters(dependant):
224
+ """Keep FastAPI extraction/aliases/defaults; replace supported validators."""
225
+ for group in ('path_params', 'query_params', 'header_params', 'cookie_params'):
226
+ for field in getattr(dependant, group):
227
+ if uses_pydantic(field.field_info.annotation):
228
+ continue
229
+ if getattr(field, '_msgspec_parameter', False):
230
+ continue
231
+ target, strict = constrained_type(field.field_info.annotation, field.field_info.metadata)
232
+ strict = bool(strict)
233
+ schema = msgspec.json.schema(target)
234
+
235
+ field.validate = _parameter_validator(target, strict)
236
+ field._msgspec_parameter = True
237
+ field._msgspec_schema = schema
238
+ for dependency in dependant.dependencies:
239
+ prepare_parameters(dependency)
@@ -0,0 +1,321 @@
1
+ """Tests for MsgspecRouter request decoding, parameter validation and response encoding."""
2
+ from typing import Annotated, Generic, TypeVar
3
+
4
+ import msgspec
5
+ import pytest
6
+ from fastapi import APIRouter, Depends, FastAPI, Query, Response
7
+ from fastapi.responses import HTMLResponse, PlainTextResponse
8
+ from fastapi.testclient import TestClient
9
+ from pydantic import BaseModel, Field
10
+
11
+ from msgspec_fastapi import MsgspecRouter, install_openapi
12
+
13
+
14
+ class City(msgspec.Struct):
15
+ population: Annotated[int, msgspec.Meta(ge=0)]
16
+
17
+
18
+ class PrivateCity(City):
19
+ secret: str
20
+
21
+
22
+ class PydCity(BaseModel):
23
+ population: Annotated[int, Field(ge=0)]
24
+
25
+
26
+ class AliasModel(BaseModel):
27
+ internal: int = Field(alias='external')
28
+
29
+
30
+ OMITTED = object()
31
+
32
+
33
+ def setup(value, *, model=OMITTED, annotated=False, **options):
34
+ router = MsgspecRouter()
35
+ if model is not OMITTED:
36
+ options['response_model'] = model
37
+ if annotated:
38
+ async def endpoint() -> City:
39
+ return value
40
+ else:
41
+ async def endpoint():
42
+ return value
43
+ router.add_api_route('/value', endpoint, methods=['GET'], **options)
44
+ app = FastAPI()
45
+ app.include_router(router)
46
+ install_openapi(app, router)
47
+ return app
48
+
49
+
50
+ def response(app, path='/value'):
51
+ return TestClient(app, raise_server_exceptions=False).get(path)
52
+
53
+
54
+ @pytest.mark.parametrize('kind', ['explicit', 'inferred'])
55
+ def test_declared_model_trusts_instance_like_pydantic(kind):
56
+ # Same as FastAPI with a Pydantic response_model: an existing instance of the
57
+ # declared class is not revalidated, so a later mutation is not detected.
58
+ value = City(1)
59
+ value.population = -1
60
+ app = setup(value, model=City if kind == 'explicit' else OMITTED, annotated=kind == 'inferred')
61
+ result = response(app)
62
+ assert result.status_code == 200 and result.json() == {'population': -1}
63
+
64
+
65
+ def test_trusted_instance_encodes_nested_values_as_is():
66
+ # Documented difference from Pydantic: only the top-level class is checked, so a
67
+ # nested subclass inside a trusted instance keeps its extra fields.
68
+ class Holder(msgspec.Struct):
69
+ city: City
70
+
71
+ app = setup(Holder(PrivateCity(1, 'PRIVATE')), model=Holder)
72
+ assert response(app).json() == {'city': {'population': 1, 'secret': 'PRIVATE'}}
73
+
74
+
75
+ def test_trusted_generic_instance_does_not_check_type_parameter():
76
+ # Documented difference from Pydantic: the type parameter of a generic Struct
77
+ # instance is not checked, only its class.
78
+ T = TypeVar('T')
79
+
80
+ class Box(msgspec.Struct, Generic[T]):
81
+ value: T
82
+
83
+ app = setup(Box('text'), model=Box[int])
84
+ assert response(app).json() == {'value': 'text'}
85
+
86
+
87
+ @pytest.mark.parametrize('annotated,model', [(True, None), (False, None), (False, OMITTED)])
88
+ def test_no_effective_model_serializes_mutated_struct(annotated, model):
89
+ value = City(1)
90
+ value.population = -1
91
+ result = response(setup(value, model=model, annotated=annotated))
92
+ assert result.status_code == 200 and result.json() == {'population': -1}
93
+
94
+
95
+ @pytest.mark.parametrize('model,status', [(City, 500), (None, 200), (OMITTED, 200)])
96
+ def test_raw_dict_constraints_follow_model_selection(model, status):
97
+ result = response(setup({'population': -1}, model=model))
98
+ assert result.status_code == status
99
+ if status == 200:
100
+ assert result.json() == {'population': -1}
101
+
102
+
103
+ def test_no_model_disables_field_projection():
104
+ value = PrivateCity(1, 'PRIVATE')
105
+ assert response(setup(value, model=City)).json() == {'population': 1}
106
+ assert response(setup(value, model=None, annotated=True)).json() == {'population': 1, 'secret': 'PRIVATE'}
107
+
108
+
109
+ @pytest.mark.parametrize('annotated,model,has_schema', [(True, OMITTED, True), (False, City, True), (True, None, False), (False, OMITTED, False)])
110
+ def test_openapi_reflects_effective_response_model(annotated, model, has_schema):
111
+ app = setup(City(1), annotated=annotated, model=model)
112
+ schema = app.openapi()['paths']['/value']['get']['responses']['200']['content']['application/json']['schema']
113
+ if has_schema:
114
+ assert schema['$ref'].endswith('/City')
115
+ else:
116
+ assert schema == {}
117
+
118
+
119
+ @pytest.mark.parametrize('model', [City, None, OMITTED])
120
+ def test_explicit_response_bypasses_model_processing(model):
121
+ result = response(setup(Response(b'ready', status_code=203, headers={'X-Ready': 'yes'}), model=model))
122
+ assert result.status_code == 203 and result.content == b'ready'
123
+ assert result.headers['X-Ready'] == 'yes'
124
+
125
+
126
+ @pytest.mark.parametrize('model', [City, None, OMITTED])
127
+ def test_included_router_keeps_status_and_headers(model):
128
+ router = MsgspecRouter()
129
+ options = {} if model is OMITTED else {'response_model': model}
130
+
131
+ @router.get('/value', status_code=201, **options)
132
+ async def endpoint(response: Response):
133
+ response.status_code = 202
134
+ response.headers['X-Endpoint'] = 'kept'
135
+ return City(1)
136
+
137
+ parent = MsgspecRouter()
138
+ parent.include_router(router, prefix='/child')
139
+ app = FastAPI()
140
+ app.include_router(parent, prefix='/api')
141
+ install_openapi(app, parent)
142
+ result = response(app, '/api/child/value')
143
+ assert result.status_code == 202 and result.json() == {'population': 1}
144
+ assert result.headers['X-Endpoint'] == 'kept'
145
+
146
+
147
+ @pytest.mark.parametrize('model', [None, OMITTED])
148
+ def test_input_validation_remains_enabled_without_output_model(model):
149
+ router = MsgspecRouter()
150
+ options = {} if model is OMITTED else {'response_model': model}
151
+
152
+ @router.post('/value', **options)
153
+ async def endpoint(city: City):
154
+ return city
155
+
156
+ app = FastAPI()
157
+ app.include_router(router)
158
+ install_openapi(app, router)
159
+ client = TestClient(app, raise_server_exceptions=False)
160
+ assert client.post('/value', json={'population': -1}).status_code == 422
161
+ assert client.post('/value', content=b'{', headers={'Content-Type': 'application/json'}).status_code == 400
162
+ assert client.post('/value', json={'population': 1}).json() == {'population': 1}
163
+
164
+
165
+ @pytest.mark.parametrize('cls', [PlainTextResponse, HTMLResponse])
166
+ @pytest.mark.parametrize('at_router', [False, True])
167
+ def test_untyped_custom_response_classes_remain_delegated(cls, at_router):
168
+ router = MsgspecRouter(**({'default_response_class': cls} if at_router else {}))
169
+ options = {} if at_router else {'response_class': cls}
170
+
171
+ @router.get('/value', **options)
172
+ async def endpoint():
173
+ return '<b>hello</b>'
174
+
175
+ app = FastAPI()
176
+ app.include_router(router)
177
+ result = response(app)
178
+ assert result.status_code == 200 and result.text == '<b>hello</b>'
179
+ assert result.headers['Content-Type'].startswith(cls.media_type)
180
+
181
+
182
+ @pytest.mark.parametrize('status', [204, 304])
183
+ def test_untyped_bodyless_status_keeps_empty_body(status):
184
+ result = response(setup(None, status_code=status))
185
+ assert result.status_code == status and result.content == b''
186
+ assert 'content-length' not in result.headers
187
+
188
+
189
+ @pytest.mark.parametrize('nested', [False, True])
190
+ def test_untyped_pydantic_values_use_public_json_aliases(nested):
191
+ value = AliasModel(external=1)
192
+ result = response(setup({'items': [value]} if nested else value))
193
+ assert result.status_code == 200
194
+ assert result.json() == ({'items': [{'external': 1}]} if nested else {'external': 1})
195
+
196
+
197
+ def test_annotated_pydantic_route_is_still_delegated():
198
+ router = MsgspecRouter()
199
+
200
+ @router.get('/value')
201
+ async def endpoint() -> PydCity:
202
+ return {'population': -1}
203
+
204
+ app = FastAPI()
205
+ app.include_router(router)
206
+ assert response(app).status_code == 500
207
+
208
+
209
+ @pytest.mark.parametrize('model', [None, OMITTED])
210
+ @pytest.mark.parametrize('options', [
211
+ {'response_model_include': {'population'}},
212
+ {'response_model_exclude': {'extra'}},
213
+ {'response_model_exclude_unset': True},
214
+ {'response_model_exclude_defaults': True},
215
+ {'response_model_exclude_none': True},
216
+ {'response_model_by_alias': False},
217
+ ])
218
+ def test_without_output_model_ignores_model_specific_options(model, options):
219
+ value = {'population': 1, 'extra': None}
220
+ result = response(setup(value, model=model, **options))
221
+ assert result.status_code == 200 and result.json() == value
222
+
223
+
224
+ @pytest.mark.parametrize('options', [
225
+ {'response_model_exclude_none': True}, {'response_model_by_alias': False},
226
+ ])
227
+ def test_typed_response_still_rejects_unsupported_model_options(options):
228
+ with pytest.raises(TypeError):
229
+ setup(City(1), model=City, **options)
230
+
231
+
232
+ def test_non_json_body_is_rejected_before_dependencies():
233
+ calls = []
234
+
235
+ def dependency():
236
+ calls.append(1)
237
+
238
+ router = MsgspecRouter()
239
+
240
+ @router.post('/value', dependencies=[Depends(dependency)])
241
+ async def endpoint(city: City):
242
+ return city
243
+
244
+ app = FastAPI()
245
+ app.include_router(router)
246
+ client = TestClient(app, raise_server_exceptions=False)
247
+ assert client.post('/value', content=b'{', headers={'Content-Type': 'application/json'}).status_code == 400
248
+ assert client.post('/value', content=b'population=1', headers={'Content-Type': 'text/plain'}).status_code == 415
249
+ assert calls == []
250
+ assert client.post('/value', json={'population': 1}).status_code == 200
251
+ assert calls == [1]
252
+
253
+
254
+ def test_body_is_decoded_strictly():
255
+ router = MsgspecRouter()
256
+
257
+ @router.post('/value')
258
+ async def endpoint(city: City):
259
+ return city
260
+
261
+ app = FastAPI()
262
+ app.include_router(router)
263
+ client = TestClient(app, raise_server_exceptions=False)
264
+ assert client.post('/value', json={'population': '1'}).status_code == 422
265
+
266
+
267
+ @pytest.mark.parametrize('prefix', ['', '/api'])
268
+ def test_parameters_are_validated_with_msgspec(prefix):
269
+ router = MsgspecRouter()
270
+
271
+ @router.get('/value/{item_id}')
272
+ async def endpoint(item_id: int, limit: Annotated[int, Query(ge=1, alias='max')] = 10,
273
+ ratio: float = 0.5):
274
+ return {'item_id': item_id, 'limit': limit, 'ratio': ratio}
275
+
276
+ app = FastAPI()
277
+ if prefix:
278
+ parent = MsgspecRouter()
279
+ parent.include_router(router)
280
+ app.include_router(parent, prefix=prefix)
281
+ install_openapi(app, parent)
282
+ else:
283
+ app.include_router(router)
284
+ install_openapi(app, router)
285
+ client = TestClient(app, raise_server_exceptions=False)
286
+ assert client.get(f'{prefix}/value/3?max=2&ratio=1.5').json() == {'item_id': 3, 'limit': 2, 'ratio': 1.5}
287
+ assert client.get(f'{prefix}/value/3').json() == {'item_id': 3, 'limit': 10, 'ratio': 0.5}
288
+ assert client.get(f'{prefix}/value/x').status_code == 422
289
+ assert client.get(f'{prefix}/value/3?max=0').status_code == 422
290
+ # Pydantic accepts surrounding whitespace for float; msgspec.convert does not, so a
291
+ # 422 here shows that msgspec, not Pydantic, validated the included route.
292
+ assert client.get(f'{prefix}/value/3', params={'ratio': ' 1.5'}).status_code == 422
293
+ parameters = app.openapi()['paths'][f'{prefix}/value/{{item_id}}']['get']['parameters']
294
+ assert next(p for p in parameters if p['name'] == 'max')['schema']['minimum'] == 1
295
+
296
+
297
+ def test_install_openapi_requires_routers():
298
+ with pytest.raises(TypeError):
299
+ install_openapi(FastAPI())
300
+
301
+
302
+ @pytest.mark.parametrize('annotation,value', [
303
+ *((int, v) for v in ['5', '+5', '05', '1_0', '5.0', '5.5', ' 5 ', 'x']),
304
+ *((bool, v) for v in ['true', 'yes', 'off', 'f', '1', 'maybe']),
305
+ ])
306
+ def test_scalar_query_coercion_matches_fastapi(annotation, value):
307
+ def make(router_class):
308
+ router = router_class()
309
+
310
+ async def endpoint(q: annotation):
311
+ return {'q': q}
312
+
313
+ router.add_api_route('/value', endpoint, methods=['GET'])
314
+ app = FastAPI()
315
+ app.include_router(router)
316
+ return TestClient(app, raise_server_exceptions=False).get('/value', params={'q': value})
317
+
318
+ expected, actual = make(APIRouter), make(MsgspecRouter)
319
+ assert actual.status_code == expected.status_code
320
+ if expected.status_code == 200:
321
+ assert actual.json() == expected.json()