diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index caeda7b..8aa2a5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,22 @@ concurrency: cancel-in-progress: true jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + enable-cache: false + - name: Install lint tools + run: uv sync --python 3.11 --only-group lint + - name: Lint + run: uv run --no-sync ruff check --output-format=github . + - name: Check formatting + run: uv run --no-sync ruff format --check . + test: name: Python ${{ matrix.python }}, ${{ matrix.bounds }} dependency bounds runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 67cb2f1..1881a8d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,7 @@ __pycache__/ *.py[cod] .venv/ .pytest_cache/ +.ruff_cache/ +.mypy_cache/ dist/ uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fdf1df7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.17.0 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/README.md b/README.md index 2a6cff8..5d3f6ad 100644 --- a/README.md +++ b/README.md @@ -164,5 +164,14 @@ Things to keep in mind: ## Development ```sh +uv sync --group test --group lint uv run --group test pytest +uv run --group lint ruff check . +uv run --group lint ruff format --check . +uv run --group lint pre-commit install +uv run --group lint pre-commit run --all-files ``` + +Ruff checks imports, annotations and common Python errors, and formats the code. +CI runs the same Ruff version as the pre-commit hooks. Test endpoints that exercise the absence +of a response annotation intentionally omit it. diff --git a/pyproject.toml b/pyproject.toml index e12c4fd..0f712bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "msgspec-fastapi" -version = "0.0.3" +version = "0.0.4" description = "Experimental FastAPI router that decodes request bodies and encodes responses with msgspec" readme = "README.md" license = "BSD-3-Clause" @@ -27,9 +27,22 @@ Issues = "https://github.com/Siyet/msgspec-fastapi/issues" [dependency-groups] test = ["pytest", "httpx", "httpx2", "python-multipart"] +lint = ["ruff==0.17.0", "pre-commit>=4.5,<5"] [tool.hatch.build.targets.wheel] packages = ["src/msgspec_fastapi"] [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "ANN"] +# Runtime type expressions, JSON values and forwarded FastAPI options are dynamic. +ignore = ["ANN401"] + +[tool.ruff.format] +quote-style = "single" diff --git a/src/msgspec_fastapi/py.typed b/src/msgspec_fastapi/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/msgspec_fastapi/responses.py b/src/msgspec_fastapi/responses.py index dc23720..b9db53e 100644 --- a/src/msgspec_fastapi/responses.py +++ b/src/msgspec_fastapi/responses.py @@ -1,10 +1,13 @@ from typing import Any + import msgspec from starlette.responses import JSONResponse _JSON_ENCODER = msgspec.json.Encoder() + class MsgspecJSONResponse(JSONResponse): """Encode JSON with msgspec; use MsgspecRouter for output validation.""" + def render(self, content: Any) -> bytes: return _JSON_ENCODER.encode(content) diff --git a/src/msgspec_fastapi/router.py b/src/msgspec_fastapi/router.py index 8a22309..4fb2439 100644 --- a/src/msgspec_fastapi/router.py +++ b/src/msgspec_fastapi/router.py @@ -1,39 +1,48 @@ from __future__ import annotations import inspect +from collections.abc import Callable, Coroutine from copy import deepcopy from functools import partial, wraps from hashlib import sha256 -from typing import Annotated, get_args, get_origin, get_type_hints +from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints import msgspec -from pydantic import BaseModel from fastapi import APIRouter, FastAPI, HTTPException, Request, Response +from fastapi._compat import ModelField from fastapi.datastructures import DefaultPlaceholder +from fastapi.dependencies.models import Dependant from fastapi.exceptions import RequestValidationError, ResponseValidationError from fastapi.openapi.utils import validation_error_definition, validation_error_response_definition from fastapi.params import Body, Depends, Form, Param from fastapi.routing import APIRoute, _effective_route_context_var, iter_route_contexts +from pydantic import BaseModel from starlette.concurrency import run_in_threadpool from .responses import MsgspecJSONResponse -from .validation import base_type, constrained_type, prepare_parameters, structured_type, uses_pydantic +from .validation import ( + base_type, + constrained_type, + prepare_parameters, + structured_type, + uses_pydantic, +) -def _untyped_encode_hook(value): +def _untyped_encode_hook(value: Any) -> Any: if isinstance(value, BaseModel): return value.model_dump(mode='json', by_alias=True) raise TypeError(f'Encoding objects of type {type(value).__name__} is unsupported') -def _contains_struct(annotation): +def _contains_struct(annotation: Any) -> bool: value = base_type(annotation) return (isinstance(value, type) and issubclass(value, msgspec.Struct)) or any( _contains_struct(arg) for arg in get_args(value) ) -def _body_type(annotation): +def _body_type(annotation: Any) -> bool: value = base_type(annotation) if structured_type(value): return True @@ -42,19 +51,19 @@ def _body_type(annotation): return any(_body_type(arg) for arg in get_args(value)) -def _metadata(parameter, annotation): +def _metadata(parameter: inspect.Parameter, annotation: Any) -> list[Any]: items = list(get_args(annotation)[1:]) if get_origin(annotation) is Annotated else [] return [*items, parameter.default] -def _merge_components(target, incoming): +def _merge_components(target: dict[str, Any], incoming: dict[str, Any]) -> None: for name, schema in incoming.items(): if name in target and target[name] != schema: raise ValueError(f'Conflicting OpenAPI component: {name}') target[name] = deepcopy(schema) -def _schema(annotation, components): +def _schema(annotation: Any, components: dict[str, Any]) -> dict[str, Any]: (schema,), definitions = msgspec.json.schema_components( [annotation], ref_template='#/components/schemas/{name}' ) @@ -62,7 +71,7 @@ def _schema(annotation, components): return schema -def _json_type(annotation): +def _json_type(annotation: Any) -> bool: if annotation in (inspect.Signature.empty, None) or uses_pydantic(annotation): return False value = base_type(annotation) @@ -76,8 +85,8 @@ def _json_type(annotation): return True -def _hints(endpoint): - target = endpoint +def _hints(endpoint: Callable[..., Any]) -> dict[str, Any]: + target: Any = endpoint while isinstance(target, partial): target = target.func if not inspect.isfunction(target) and not inspect.ismethod(target): @@ -85,12 +94,19 @@ def _hints(endpoint): return get_type_hints(target, include_extras=True) -def _parse_body(request, raw, config, strict_content_type): +def _parse_body( + request: Request, + raw: bytes, + config: tuple[msgspec.json.Decoder[Any], Any, bool], + strict_content_type: bool, +) -> Any: decoder, default, embedded = config if not raw: if default is inspect.Signature.empty: # Raised after dependencies run, as in FastAPI. - return RequestValidationError([{'loc': ('body',), 'msg': 'Field required', 'type': 'missing'}]) + return RequestValidationError( + [{'loc': ('body',), 'msg': 'Field required', 'type': 'missing'}] + ) return deepcopy(default) content_type = request.headers.get('content-type') if content_type: @@ -113,7 +129,9 @@ def _parse_body(request, raw, config, strict_content_type): raise HTTPException(400, f'Malformed JSON body: {syntax}') from syntax except RecursionError as nested: raise HTTPException(400, 'JSON body is nested too deeply') from nested - return RequestValidationError([{'loc': ('body',), 'msg': str(exc), 'type': 'value_error.msgspec'}]) + return RequestValidationError( + [{'loc': ('body',), 'msg': str(exc), 'type': 'value_error.msgspec'}] + ) except (msgspec.DecodeError, UnicodeError) as exc: raise HTTPException(400, f'Malformed JSON body: {exc}') from exc except RecursionError as exc: @@ -121,7 +139,7 @@ def _parse_body(request, raw, config, strict_content_type): return value.value if embedded else value -def _revalidate_without_json(result, output_type, error): +def _revalidate_without_json(result: Any, output_type: Any, error: Exception) -> Any: """Validate a value whose JSON round trip failed, as for NaN and infinity. JSON encodes non-finite floats as null, and null is not valid for a float field. @@ -133,18 +151,21 @@ def _revalidate_without_json(result, output_type, error): """ if isinstance(error, msgspec.ValidationError): try: - return msgspec.convert(msgspec.to_builtins(result, str_keys=True), output_type, str_keys=True) + return msgspec.convert( + msgspec.to_builtins(result, str_keys=True), output_type, str_keys=True + ) except msgspec.ValidationError as exc: error = exc except TypeError: # to_builtins does not support every value JSON encodes, such as msgspec.Raw. pass raise ResponseValidationError( - [{'loc': ('response',), 'msg': str(error), 'type': 'value_error.msgspec'}]) from error + [{'loc': ('response',), 'msg': str(error), 'type': 'value_error.msgspec'}] + ) from error class MsgspecRoute(APIRoute): - def get_route_handler(self): + def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: context = _effective_route_context_var.get() route = context if context is not None and context.original_route is self else self prepare_parameters(route.dependant) @@ -159,11 +180,13 @@ def get_route_handler(self): if isinstance(strict_content_type, DefaultPlaceholder): strict_content_type = strict_content_type.value - async def handle(request): + async def handle(request: Request) -> Response: # A body that is not JSON is rejected before dependency side effects. request.scope['_msgspec_body_value'] = _parse_body( - request, await request.body(), config, strict_content_type) + request, await request.body(), config, strict_content_type + ) return await handler(request) + return handle @@ -178,7 +201,7 @@ def install_openapi(app: FastAPI, *routers: 'MsgspecRouter') -> None: original = app.openapi app.openapi_schema = None - def openapi(): + def openapi() -> dict[str, Any]: document = original() components = document.setdefault('components', {}).setdefault('schemas', {}) for router in routers: @@ -187,48 +210,54 @@ def openapi(): original_route = getattr(route, 'original_route', route) if not isinstance(original_route, MsgspecRoute): continue - fields = [] - def collect(dependant): + fields: list[ModelField] = [] + + def collect(dependant: Dependant) -> None: for group in ('path_params', 'query_params', 'header_params', 'cookie_params'): fields.extend(getattr(dependant, group)) for dependency in dependant.dependencies: collect(dependency) + collect(route.dependant) - for method in route.methods: - operation = document.get('paths', {}).get(route.path_format, {}).get(method.lower(), {}) + for method in route.methods or (): + operation = ( + document.get('paths', {}).get(route.path_format, {}).get(method.lower(), {}) + ) for parameter in operation.get('parameters', []): for field in fields: - if (getattr(field, '_msgspec_parameter', False) - and parameter['in'] == field.field_info.in_.value - and parameter['name'] == field.alias): - schema = deepcopy(field._msgspec_schema) + if ( + getattr(field, '_msgspec_parameter', False) + and parameter['in'] == cast(Param, field.field_info).in_.value + and parameter['name'] == field.alias + ): + schema = deepcopy(getattr(field, '_msgspec_schema')) for key in ('title', 'description', 'default'): if key in parameter.get('schema', {}): schema[key] = parameter['schema'][key] parameter['schema'] = schema return document - app.openapi = openapi - app._msgspec_openapi_installed = True + setattr(app, 'openapi', openapi) + setattr(app, '_msgspec_openapi_installed', True) class MsgspecRouter(APIRouter): """Typed msgspec JSON bodies and validated responses for ordinary CRUD routes.""" - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: kwargs.setdefault('route_class', MsgspecRoute) kwargs.setdefault('default_response_class', MsgspecJSONResponse) if not issubclass(kwargs['route_class'], MsgspecRoute): raise TypeError('route_class must extend MsgspecRoute') super().__init__(*args, **kwargs) - self.msgspec_components = {} + self.msgspec_components: dict[str, Any] = {} - def include_router(self, router, *args, **kwargs): + def include_router(self, router: APIRouter, *args: Any, **kwargs: Any) -> None: if isinstance(router, MsgspecRouter): _merge_components(self.msgspec_components, router.msgspec_components) return super().include_router(router, *args, **kwargs) - def add_api_route(self, path, endpoint, **kwargs): + def add_api_route(self, path: str, endpoint: Callable[..., Any], **kwargs: Any) -> None: override = kwargs.get('route_class_override') if override is not None and not issubclass(override, MsgspecRoute): raise TypeError('route_class_override must extend MsgspecRoute') @@ -238,7 +267,10 @@ def add_api_route(self, path, endpoint, **kwargs): return super().add_api_route(path, endpoint, **kwargs) signature = inspect.signature(endpoint) hints = _hints(endpoint) - parameters = [p.replace(annotation=hints.get(p.name, p.annotation)) for p in signature.parameters.values()] + parameters = [ + p.replace(annotation=hints.get(p.name, p.annotation)) + for p in signature.parameters.values() + ] return_type = hints.get('return', signature.return_annotation) if return_type is type(None): # As in FastAPI, `-> None` declares no response model. @@ -257,8 +289,14 @@ def add_api_route(self, path, endpoint, **kwargs): # including subclass instances, are validated against the schema. Unlike Pydantic, # values nested in a trusted instance are encoded as they are (a nested subclass keeps # its extra fields, and the type parameters of a generic Struct are not checked). - output_class = (get_origin(base_type(output_type)) or base_type(output_type)) if validates_output else None - trusts_instances = isinstance(output_class, type) and issubclass(output_class, msgspec.Struct) + output_class = ( + (get_origin(base_type(output_type)) or base_type(output_type)) + if validates_output + else None + ) + trusts_instances = isinstance(output_class, type) and issubclass( + output_class, msgspec.Struct + ) output_encoder = msgspec.json.Encoder() if validates_output else None response_class = kwargs.get('response_class') if response_class is None or isinstance(response_class, DefaultPlaceholder): @@ -270,12 +308,15 @@ def add_api_route(self, path, endpoint, **kwargs): and not uses_pydantic(return_type) and response_class is MsgspecJSONResponse ) - handles_output = validates_output or serializes_untyped_output or ( - explicit is None and _contains_struct(return_type) + handles_output = ( + validates_output + or serializes_untyped_output + or (explicit is None and _contains_struct(return_type)) ) untyped_encoder = ( msgspec.json.Encoder(enc_hook=_untyped_encode_hook) - if serializes_untyped_output else None + if serializes_untyped_output + else None ) body = None body_meta = None @@ -288,14 +329,20 @@ def add_api_route(self, path, endpoint, **kwargs): if _contains_struct(parameter.annotation): raise TypeError('msgspec Structs are not supported as Form or File parameters') continue - is_body = _contains_struct(parameter.annotation) or any(isinstance(x, Body) for x in metadata) + is_body = _contains_struct(parameter.annotation) or any( + isinstance(x, Body) for x in metadata + ) if _body_type(parameter.annotation) and not any(isinstance(x, Param) for x in metadata): is_body = True if is_body and _json_type(parameter.annotation): if any(isinstance(x, Param) for x in metadata): - raise TypeError('msgspec Struct query/path/header/cookie parameters are not supported') + raise TypeError( + 'msgspec Struct query/path/header/cookie parameters are not supported' + ) if body is not None: - raise TypeError('Only one msgspec body parameter is supported; other body fields cannot be combined') + raise TypeError( + 'Only one msgspec body parameter is supported; other body fields cannot be combined' + ) body = parameter body_meta = next((x for x in metadata if isinstance(x, Body)), None) @@ -305,9 +352,13 @@ def add_api_route(self, path, endpoint, **kwargs): raise TypeError('Variadic msgspec endpoints are not supported') if handles_output: if validates_output: - for option in ('response_model_include', 'response_model_exclude', - 'response_model_exclude_unset', 'response_model_exclude_defaults', - 'response_model_exclude_none'): + for option in ( + 'response_model_include', + 'response_model_exclude', + 'response_model_exclude_unset', + 'response_model_exclude_defaults', + 'response_model_exclude_none', + ): if kwargs.get(option): raise TypeError(f'{option} is not supported for msgspec responses') if kwargs.get('response_model_by_alias', True) is False: @@ -315,19 +366,29 @@ def add_api_route(self, path, endpoint, **kwargs): if validates_output and kwargs.get('status_code') in (204, 304): raise TypeError('A typed msgspec response requires a response body') response_class = kwargs.get('response_class') - if response_class is not None and not isinstance(response_class, DefaultPlaceholder) and response_class is not MsgspecJSONResponse: - raise TypeError('Custom response_class is not supported for typed msgspec responses') + if ( + response_class is not None + and not isinstance(response_class, DefaultPlaceholder) + and response_class is not MsgspecJSONResponse + ): + raise TypeError( + 'Custom response_class is not supported for typed msgspec responses' + ) kwargs['response_model'] = None kwargs['response_class'] = MsgspecJSONResponse - components = {} + components: dict[str, Any] = {} responses = deepcopy(kwargs.get('responses') or {}) openapi_extra = deepcopy(kwargs.get('openapi_extra') or {}) default = inspect.Signature.empty decoder = None embedded = False if body is not None: - metadata = [x for x in get_args(body.annotation)[1:] if not isinstance(x, Body)] if get_origin(body.annotation) is Annotated else [] + metadata = ( + [x for x in get_args(body.annotation)[1:] if not isinstance(x, Body)] + if get_origin(body.annotation) is Annotated + else [] + ) body_type, body_strict = constrained_type(base_type(body.annotation), metadata) if body_strict is False: raise TypeError('JSON msgspec bodies use strict validation') @@ -340,9 +401,13 @@ def add_api_route(self, path, endpoint, **kwargs): embedded = body_meta.embed is True default = body_meta.default from pydantic_core import PydanticUndefined + if default is PydanticUndefined or default is Ellipsis: default = inspect.Signature.empty - if not isinstance(body.default, Body) and body.default is not inspect.Signature.empty: + if ( + not isinstance(body.default, Body) + and body.default is not inspect.Signature.empty + ): default = body.default if body_meta.default_factory is not None: raise TypeError('Body default_factory is not supported') @@ -350,39 +415,71 @@ def add_api_route(self, path, endpoint, **kwargs): default = body.default decode_type = body_type if embedded: + assert body_meta is not None # One explicit JSON envelope, preserving a possible Body alias. - field = ('value', body_type) if default is inspect.Signature.empty else ( - 'value', body_type, msgspec.field(default_factory=lambda: deepcopy(default)) + field = ( + ('value', body_type) + if default is inspect.Signature.empty + else ( + 'value', + body_type, + msgspec.field(default_factory=lambda: deepcopy(default)), + ) ) - identity = {'schema': msgspec.json.schema(body_type), 'alias': body_meta.alias or body.name, - 'required': default is inspect.Signature.empty} + identity = { + 'schema': msgspec.json.schema(body_type), + 'alias': body_meta.alias or body.name, + 'required': default is inspect.Signature.empty, + } if default is not inspect.Signature.empty: identity['default'] = default name = 'MsgspecBody_' + sha256(msgspec.json.encode(identity)).hexdigest()[:16] - decode_type = msgspec.defstruct(name, [field], - rename={'value': body_meta.alias or body.name}) + decode_type = msgspec.defstruct( + name, [field], rename={'value': body_meta.alias or body.name} + ) decoder = msgspec.json.Decoder(type=decode_type) body_schema = _schema(decode_type, components) - openapi_extra['requestBody'] = {'required': default is inspect.Signature.empty, - 'content': {'application/json': {'schema': body_schema}}} + openapi_extra['requestBody'] = { + 'required': default is inspect.Signature.empty, + 'content': {'application/json': {'schema': body_schema}}, + } responses.setdefault(400, {'description': 'Malformed JSON'}) - responses.setdefault(422, {'description': 'Validation Error', 'content': { - 'application/json': {'schema': {'$ref': '#/components/schemas/HTTPValidationError'}}}}) - _merge_components(components, {'HTTPValidationError': validation_error_response_definition, - 'ValidationError': validation_error_definition}) + responses.setdefault( + 422, + { + 'description': 'Validation Error', + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/HTTPValidationError'} + } + }, + }, + ) + _merge_components( + components, + { + 'HTTPValidationError': validation_error_response_definition, + 'ValidationError': validation_error_definition, + }, + ) if validates_output: code = kwargs.get('status_code') or 200 key = str(code) if str(code) in responses else code result_meta = responses.setdefault(key, {}) - result_meta.setdefault('description', kwargs.get('response_description', 'Successful Response')) - result_meta.setdefault('content', {})['application/json'] = {'schema': _schema(output_type, components)} + result_meta.setdefault( + 'description', kwargs.get('response_description', 'Successful Response') + ) + result_meta.setdefault('content', {})['application/json'] = { + 'schema': _schema(output_type, components) + } previous_components = deepcopy(self.msgspec_components) _merge_components(self.msgspec_components, components) kwargs['responses'] = responses kwargs['openapi_extra'] = openapi_extra remaining = [p for p in parameters if p is not body] - def injected_name(type_, fallback): + + def injected_name(type_: type[Request] | type[Response], fallback: str) -> tuple[str, bool]: for p in remaining: annotation = base_type(p.annotation) if isinstance(annotation, type) and issubclass(annotation, type_): @@ -390,21 +487,31 @@ def injected_name(type_, fallback): names = {p.name for p in parameters} while fallback in names: fallback += '_' - remaining.append(inspect.Parameter(fallback, kind=inspect.Parameter.KEYWORD_ONLY, annotation=type_)) + remaining.append( + inspect.Parameter(fallback, kind=inspect.Parameter.KEYWORD_ONLY, annotation=type_) + ) return fallback, True - request_name, remove_request = injected_name(Request, '__msgspec_request') if body else (None, False) - response_name, remove_response = injected_name(Response, '__msgspec_response') if handles_output else (None, False) - is_async = inspect.iscoroutinefunction(endpoint) or inspect.iscoroutinefunction(getattr(endpoint, '__call__', None)) + request_name, remove_request = ( + injected_name(Request, '__msgspec_request') if body else (None, False) + ) + response_name, remove_response = ( + injected_name(Response, '__msgspec_response') if handles_output else (None, False) + ) + + is_async = inspect.iscoroutinefunction(endpoint) or inspect.iscoroutinefunction( + getattr(endpoint, '__call__', None) + ) + @wraps(endpoint) - async def wrapper(**values): - sub_response = values.get(response_name) + async def wrapper(**values: Any) -> Any: + sub_response = cast(Response, values.get(cast(str, response_name))) if remove_response: - values.pop(response_name) + values.pop(cast(str, response_name)) if body is not None: - request = values[request_name] + request = values[cast(str, request_name)] if remove_request: - values.pop(request_name) + values.pop(cast(str, request_name)) value = request.scope['_msgspec_body_value'] if isinstance(value, RequestValidationError): raise value @@ -422,13 +529,18 @@ async def wrapper(**values): if validates_output and not (trusts_instances and type(result) is output_class): try: # Typed decoding validates fields and Meta, even for existing Structs. - result = output_decoder.decode(output_encoder.encode(result)) + result = cast(msgspec.json.Decoder[Any], output_decoder).decode( + cast(msgspec.json.Encoder, output_encoder).encode(result) + ) except (TypeError, ValueError, OverflowError) as exc: result = _revalidate_without_json(result, output_type, exc) status_code = sub_response.status_code or kwargs.get('status_code') or 200 if untyped_encoder is not None: - response = Response(untyped_encoder.encode(result), status_code=status_code, - media_type='application/json') + response = Response( + untyped_encoder.encode(result), + status_code=status_code, + media_type='application/json', + ) else: response = MsgspecJSONResponse(result, status_code=status_code) if response.status_code < 200 or response.status_code in (204, 205, 304): @@ -438,18 +550,24 @@ async def wrapper(**values): response.raw_headers.extend(sub_response.raw_headers) return response - wrapper.__signature__ = signature.replace(parameters=remaining, return_annotation=inspect.Signature.empty if handles_output else return_type) - wrapper._msgspec_prepared = True + setattr( + wrapper, + '__signature__', + signature.replace( + parameters=remaining, + return_annotation=inspect.Signature.empty if handles_output else return_type, + ), + ) + setattr(wrapper, '_msgspec_prepared', True) if body is not None: - wrapper._msgspec_body_config = (decoder, default, embedded) + setattr(wrapper, '_msgspec_body_config', (decoder, default, embedded)) route_count = len(self.routes) try: result = super().add_api_route(path, wrapper, **kwargs) - if body is not None and self.routes[-1].body_field is not None: + if body is not None and cast(APIRoute, self.routes[-1]).body_field is not None: raise TypeError('Other FastAPI body fields cannot be combined with a msgspec body') return result except Exception: del self.routes[route_count:] self.msgspec_components = previous_components raise - diff --git a/src/msgspec_fastapi/validation.py b/src/msgspec_fastapi/validation.py index 66f729a..4304c87 100644 --- a/src/msgspec_fastapi/validation.py +++ b/src/msgspec_fastapi/validation.py @@ -1,37 +1,43 @@ """Route-local validation for HTTP parameters; no global FastAPI changes.""" -import re -from typing import Annotated, Any, Literal, Union, get_args, get_origin, get_type_hints +from __future__ import annotations + +import re +from collections.abc import Callable, Iterable from dataclasses import is_dataclass -from typing_extensions import is_typeddict from types import UnionType +from typing import Annotated, Any, Literal, Union, cast, get_args, get_origin, get_type_hints import annotated_types import msgspec +from fastapi.dependencies.models import Dependant from pydantic import BaseModel from pydantic.fields import FieldInfo +from typing_extensions import is_typeddict -def base_type(annotation): +def base_type(annotation: Any) -> Any: return get_args(annotation)[0] if get_origin(annotation) is Annotated else annotation -def structured_type(annotation): +def structured_type(annotation: Any) -> bool: value = get_origin(base_type(annotation)) or base_type(annotation) return isinstance(value, type) and ( - issubclass(value, msgspec.Struct) or is_typeddict(value) or is_dataclass(value) + issubclass(value, msgspec.Struct) + or is_typeddict(value) + or is_dataclass(value) or (issubclass(value, tuple) and hasattr(value, '_fields')) ) -def uses_pydantic(annotation): +def uses_pydantic(annotation: Any) -> bool: value = base_type(annotation) return (isinstance(value, type) and issubclass(value, BaseModel)) or any( uses_pydantic(item) for item in get_args(value) ) -def _check_structured_metadata(annotation, seen=None): +def _check_structured_metadata(annotation: Any, seen: set[Any] | None = None) -> None: if seen is None: seen = set() if get_origin(annotation) is Annotated: @@ -49,7 +55,7 @@ def _check_structured_metadata(annotation, seen=None): _check_structured_metadata(arg, seen) -def constrained_type(annotation, metadata=()): +def constrained_type(annotation: Any, metadata: Iterable[Any] = ()) -> tuple[Any, bool | None]: """Translate supported FastAPI constraints, rejecting unhandled validation.""" if get_origin(annotation) is Annotated: annotation, *extras = get_args(annotation) @@ -58,22 +64,24 @@ def constrained_type(annotation, metadata=()): if structured_type(annotation): _check_structured_metadata(annotation) if args and origin is not Literal: - converted = [] + converted: list[Any] = [] for arg in args: if arg is Ellipsis: converted.append(arg) continue target, nested_strict = constrained_type(arg) if nested_strict is not None: - raise TypeError('Nested strict constraints are not supported; use a strict top-level parameter') + raise TypeError( + 'Nested strict constraints are not supported; use a strict top-level parameter' + ) converted.append(target) - converted = tuple(converted) + converted_args = tuple(converted) if hasattr(annotation, 'copy_with'): - annotation = annotation.copy_with(converted) + annotation = annotation.copy_with(converted_args) elif origin in (Union, UnionType): - annotation = Union[converted] + annotation = Union[converted_args] else: - annotation = origin[converted[0] if len(converted) == 1 else converted] + annotation = origin[converted_args[0] if len(converted_args) == 1 else converted_args] expanded = [] for item in metadata: if isinstance(item, FieldInfo): @@ -81,21 +89,35 @@ def constrained_type(annotation, metadata=()): else: expanded.append(item) metadata = expanded - constraints = {} + constraints: dict[str, Any] = {} metas = [] strict = None for item in metadata: if isinstance(item, msgspec.Meta): metas.append(item) - elif isinstance(item, (annotated_types.Gt, annotated_types.Ge, annotated_types.Lt, - annotated_types.Le, annotated_types.MultipleOf, - annotated_types.MinLen, annotated_types.MaxLen)): - name = {'MinLen': 'min_length', 'MaxLen': 'max_length'}.get(type(item).__name__, - {'MultipleOf': 'multiple_of'}.get(type(item).__name__, type(item).__name__.lower())) + elif isinstance( + item, + ( + annotated_types.Gt, + annotated_types.Ge, + annotated_types.Lt, + annotated_types.Le, + annotated_types.MultipleOf, + annotated_types.MinLen, + annotated_types.MaxLen, + ), + ): + name = {'MinLen': 'min_length', 'MaxLen': 'max_length'}.get( + type(item).__name__, + {'MultipleOf': 'multiple_of'}.get(type(item).__name__, type(item).__name__.lower()), + ) constraints[name] = getattr(item, name) elif type(item).__module__.startswith('pydantic') and type(item).__name__ == 'Strict': strict = item.strict - elif type(item).__module__.startswith('pydantic') and type(item).__name__ == '_PydanticGeneralMetadata': + elif ( + type(item).__module__.startswith('pydantic') + and type(item).__name__ == '_PydanticGeneralMetadata' + ): for name, value in vars(item).items(): if name not in ('pattern',): raise TypeError(f'Unsupported msgspec parameter constraint: {name}') @@ -117,13 +139,12 @@ def constrained_type(annotation, metadata=()): return target, strict - # Unicode White_Space used by Rust str::trim, excluding Python's extra C0 separators. _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' _HTTP_INTEGER = re.compile(r'-?(?:0|[1-9][0-9]*)\Z') -def _http_int(value): +def _http_int(value: Any) -> Any: """Match pydantic-core 2.46.5 str_as_int/clean_int_str on HTTP strings.""" if not isinstance(value, str): return value @@ -151,7 +172,7 @@ def _http_int(value): number = number[offset:] break if char == '.': - number = number[offset - 1:] + number = number[offset - 1 :] break raise ValueError('Invalid integer parameter') else: @@ -160,8 +181,12 @@ def _http_int(value): whole, decimal = number.split('.', 1) if decimal and all(char == '0' for char in decimal): number = whole - if ('_' in number and not number.startswith('_') and not number.endswith('_') - and '__' not in number): + if ( + '_' in number + and not number.startswith('_') + and not number.endswith('_') + and '__' not in number + ): number = number.replace('_', '') if negative: number = '-' + number @@ -169,7 +194,8 @@ def _http_int(value): raise ValueError('Invalid integer parameter') return int(number) -def _http_normalizer(annotation): + +def _http_normalizer(annotation: Any) -> Callable[[Any], Any] | None: """Compile supported transport coercions once at route registration.""" annotation = base_type(annotation) origin, args = get_origin(annotation), get_args(annotation) @@ -180,20 +206,33 @@ def _http_normalizer(annotation): children = tuple(_http_normalizer(arg) for arg in args) if not any(children): return None - def normalize_tuple(value): - return [children[i](item) if i < len(children) and children[i] else item - for i, item in enumerate(value)] if isinstance(value, (list, tuple)) else value + + def normalize_tuple(value: Any) -> Any: + return ( + [ + cast(Callable[[Any], Any], children[i])(item) + if i < len(children) and children[i] + else item + for i, item in enumerate(value) + ] + if isinstance(value, (list, tuple)) + else value + ) + return normalize_tuple child = _http_normalizer(args[0]) if args else None if child is None: return None - def normalize_sequence(value): + + def normalize_sequence(value: Any) -> Any: return [child(item) for item in value] if isinstance(value, (list, tuple)) else value + return normalize_sequence if annotation is int: return _http_int if annotation is bool: - def normalize_bool(value): + + def normalize_bool(value: Any) -> Any: if isinstance(value, str): lowered = value.lower() if lowered in ('yes', 'on', 'y', 't'): @@ -201,26 +240,33 @@ def normalize_bool(value): if lowered in ('no', 'off', 'n', 'f'): return False return value + return normalize_bool if annotation is bytes: return lambda value: value.encode('utf-8') if isinstance(value, str) else value return None -def _parameter_validator(target, strict): +def _parameter_validator( + target: Any, strict: bool +) -> Callable[..., tuple[Any, list[dict[str, Any]]]]: normalizer = None if strict else _http_normalizer(target) convert = msgspec.convert - def validate(value: Any, values=None, *, loc=()): + + def validate( + value: Any, values: dict[str, Any] | None = None, *, loc: tuple[str | int, ...] = () + ) -> tuple[Any, list[dict[str, Any]]]: try: if normalizer is not None: value = normalizer(value) return convert(value, type=target, strict=strict), [] except (ValueError, UnicodeError, OverflowError) as exc: return None, [{'loc': loc, 'msg': str(exc), 'type': 'value_error.msgspec'}] + return validate -def prepare_parameters(dependant): +def prepare_parameters(dependant: Dependant) -> None: """Keep FastAPI extraction/aliases/defaults; replace supported validators.""" for group in ('path_params', 'query_params', 'header_params', 'cookie_params'): for field in getattr(dependant, group): @@ -228,7 +274,9 @@ def prepare_parameters(dependant): continue if getattr(field, '_msgspec_parameter', False): continue - target, strict = constrained_type(field.field_info.annotation, field.field_info.metadata) + target, strict = constrained_type( + field.field_info.annotation, field.field_info.metadata + ) strict = bool(strict) schema = msgspec.json.schema(target) diff --git a/tests/test_router.py b/tests/test_router.py index 184c6a2..e93a967 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,6 +1,9 @@ """Tests for MsgspecRouter request decoding, parameter validation and response encoding.""" + from typing import Annotated, Any, Generic, TypeVar +import httpx +import httpx2 import msgspec import pytest from fastapi import APIRouter, Depends, FastAPI, File, Form, Header, HTTPException, Query, Response @@ -31,16 +34,19 @@ class AliasModel(BaseModel): OMITTED = object() -def setup(value, *, model=OMITTED, annotated=False, **options): +def setup(value: Any, *, model: Any = OMITTED, annotated: bool = False, **options: Any) -> FastAPI: router = MsgspecRouter() if model is not OMITTED: options['response_model'] = model if annotated: + async def endpoint() -> City: return value else: - async def endpoint(): + + async def endpoint(): # noqa: ANN202 - Test an endpoint without a return annotation. return value + router.add_api_route('/value', endpoint, methods=['GET'], **options) app = FastAPI() app.include_router(router) @@ -48,12 +54,12 @@ async def endpoint(): return app -def response(app, path='/value'): +def response(app: FastAPI, path: str = '/value') -> httpx.Response | httpx2.Response: return TestClient(app, raise_server_exceptions=False).get(path) @pytest.mark.parametrize('kind', ['explicit', 'inferred']) -def test_declared_model_trusts_instance_like_pydantic(kind): +def test_declared_model_trusts_instance_like_pydantic(kind: str) -> None: # Same as FastAPI with a Pydantic response_model: an existing instance of the # declared class is not revalidated, so a later mutation is not detected. value = City(1) @@ -63,7 +69,7 @@ def test_declared_model_trusts_instance_like_pydantic(kind): assert result.status_code == 200 and result.json() == {'population': -1} -def test_trusted_instance_encodes_nested_values_as_is(): +def test_trusted_instance_encodes_nested_values_as_is() -> None: # Documented difference from Pydantic: only the top-level class is checked, so a # nested subclass inside a trusted instance keeps its extra fields. class Holder(msgspec.Struct): @@ -73,7 +79,7 @@ class Holder(msgspec.Struct): assert response(app).json() == {'city': {'population': 1, 'secret': 'PRIVATE'}} -def test_trusted_generic_instance_does_not_check_type_parameter(): +def test_trusted_generic_instance_does_not_check_type_parameter() -> None: # Documented difference from Pydantic: the type parameter of a generic Struct # instance is not checked, only its class. T = TypeVar('T') @@ -86,7 +92,7 @@ class Box(msgspec.Struct, Generic[T]): @pytest.mark.parametrize('annotated,model', [(True, None), (False, None), (False, OMITTED)]) -def test_no_effective_model_serializes_mutated_struct(annotated, model): +def test_no_effective_model_serializes_mutated_struct(annotated: bool, model: Any) -> None: value = City(1) value.population = -1 result = response(setup(value, model=model, annotated=annotated)) @@ -94,23 +100,33 @@ def test_no_effective_model_serializes_mutated_struct(annotated, model): @pytest.mark.parametrize('model,status', [(City, 500), (None, 200), (OMITTED, 200)]) -def test_raw_dict_constraints_follow_model_selection(model, status): +def test_raw_dict_constraints_follow_model_selection(model: Any, status: int) -> None: result = response(setup({'population': -1}, model=model)) assert result.status_code == status if status == 200: assert result.json() == {'population': -1} -def test_no_model_disables_field_projection(): +def test_no_model_disables_field_projection() -> None: value = PrivateCity(1, 'PRIVATE') assert response(setup(value, model=City)).json() == {'population': 1} - assert response(setup(value, model=None, annotated=True)).json() == {'population': 1, 'secret': 'PRIVATE'} - - -@pytest.mark.parametrize('annotated,model,has_schema', [(True, OMITTED, True), (False, City, True), (True, None, False), (False, OMITTED, False)]) -def test_openapi_reflects_effective_response_model(annotated, model, has_schema): + assert response(setup(value, model=None, annotated=True)).json() == { + 'population': 1, + 'secret': 'PRIVATE', + } + + +@pytest.mark.parametrize( + 'annotated,model,has_schema', + [(True, OMITTED, True), (False, City, True), (True, None, False), (False, OMITTED, False)], +) +def test_openapi_reflects_effective_response_model( + annotated: bool, model: Any, has_schema: bool +) -> None: app = setup(City(1), annotated=annotated, model=model) - schema = app.openapi()['paths']['/value']['get']['responses']['200']['content']['application/json']['schema'] + schema = app.openapi()['paths']['/value']['get']['responses']['200']['content'][ + 'application/json' + ]['schema'] if has_schema: assert schema['$ref'].endswith('/City') else: @@ -118,19 +134,21 @@ def test_openapi_reflects_effective_response_model(annotated, model, has_schema) @pytest.mark.parametrize('model', [City, None, OMITTED]) -def test_explicit_response_bypasses_model_processing(model): - result = response(setup(Response(b'ready', status_code=203, headers={'X-Ready': 'yes'}), model=model)) +def test_explicit_response_bypasses_model_processing(model: Any) -> None: + result = response( + setup(Response(b'ready', status_code=203, headers={'X-Ready': 'yes'}), model=model) + ) assert result.status_code == 203 and result.content == b'ready' assert result.headers['X-Ready'] == 'yes' @pytest.mark.parametrize('model', [City, None, OMITTED]) -def test_included_router_keeps_status_and_headers(model): +def test_included_router_keeps_status_and_headers(model: Any) -> None: router = MsgspecRouter() options = {} if model is OMITTED else {'response_model': model} @router.get('/value', status_code=201, **options) - async def endpoint(response: Response): + async def endpoint(response: Response): # noqa: ANN202 - Test an endpoint without a return annotation. response.status_code = 202 response.headers['X-Endpoint'] = 'kept' return City(1) @@ -146,12 +164,12 @@ async def endpoint(response: Response): @pytest.mark.parametrize('model', [None, OMITTED]) -def test_input_validation_remains_enabled_without_output_model(model): +def test_input_validation_remains_enabled_without_output_model(model: Any) -> None: router = MsgspecRouter() options = {} if model is OMITTED else {'response_model': model} @router.post('/value', **options) - async def endpoint(city: City): + async def endpoint(city: City): # noqa: ANN202 - Test an endpoint without a return annotation. return city app = FastAPI() @@ -159,18 +177,25 @@ async def endpoint(city: City): install_openapi(app, router) client = TestClient(app, raise_server_exceptions=False) assert client.post('/value', json={'population': -1}).status_code == 422 - assert client.post('/value', content=b'{', headers={'Content-Type': 'application/json'}).status_code == 400 + assert ( + client.post( + '/value', content=b'{', headers={'Content-Type': 'application/json'} + ).status_code + == 400 + ) assert client.post('/value', json={'population': 1}).json() == {'population': 1} @pytest.mark.parametrize('cls', [PlainTextResponse, HTMLResponse]) @pytest.mark.parametrize('at_router', [False, True]) -def test_untyped_custom_response_classes_remain_delegated(cls, at_router): +def test_untyped_custom_response_classes_remain_delegated( + cls: type[Response], at_router: bool +) -> None: router = MsgspecRouter(**({'default_response_class': cls} if at_router else {})) options = {} if at_router else {'response_class': cls} @router.get('/value', **options) - async def endpoint(): + async def endpoint(): # noqa: ANN202 - Test an endpoint without a return annotation. return 'hello' app = FastAPI() @@ -181,21 +206,21 @@ async def endpoint(): @pytest.mark.parametrize('status', [204, 304]) -def test_untyped_bodyless_status_keeps_empty_body(status): +def test_untyped_bodyless_status_keeps_empty_body(status: int) -> None: result = response(setup(None, status_code=status)) assert result.status_code == status and result.content == b'' assert 'content-length' not in result.headers @pytest.mark.parametrize('nested', [False, True]) -def test_untyped_pydantic_values_use_public_json_aliases(nested): +def test_untyped_pydantic_values_use_public_json_aliases(nested: bool) -> None: value = AliasModel(external=1) result = response(setup({'items': [value]} if nested else value)) assert result.status_code == 200 assert result.json() == ({'items': [{'external': 1}]} if nested else {'external': 1}) -def test_annotated_pydantic_route_is_still_delegated(): +def test_annotated_pydantic_route_is_still_delegated() -> None: router = MsgspecRouter() @router.get('/value') @@ -208,55 +233,74 @@ async def endpoint() -> PydCity: @pytest.mark.parametrize('model', [None, OMITTED]) -@pytest.mark.parametrize('options', [ - {'response_model_include': {'population'}}, - {'response_model_exclude': {'extra'}}, - {'response_model_exclude_unset': True}, - {'response_model_exclude_defaults': True}, - {'response_model_exclude_none': True}, - {'response_model_by_alias': False}, -]) -def test_without_output_model_ignores_model_specific_options(model, options): +@pytest.mark.parametrize( + 'options', + [ + {'response_model_include': {'population'}}, + {'response_model_exclude': {'extra'}}, + {'response_model_exclude_unset': True}, + {'response_model_exclude_defaults': True}, + {'response_model_exclude_none': True}, + {'response_model_by_alias': False}, + ], +) +def test_without_output_model_ignores_model_specific_options( + model: Any, options: dict[str, Any] +) -> None: value = {'population': 1, 'extra': None} result = response(setup(value, model=model, **options)) assert result.status_code == 200 and result.json() == value -@pytest.mark.parametrize('options', [ - {'response_model_exclude_none': True}, {'response_model_by_alias': False}, -]) -def test_typed_response_still_rejects_unsupported_model_options(options): +@pytest.mark.parametrize( + 'options', + [ + {'response_model_exclude_none': True}, + {'response_model_by_alias': False}, + ], +) +def test_typed_response_still_rejects_unsupported_model_options(options: dict[str, Any]) -> None: with pytest.raises(TypeError): setup(City(1), model=City, **options) -def test_non_json_body_is_rejected_before_dependencies(): +def test_non_json_body_is_rejected_before_dependencies() -> None: calls = [] - def dependency(): + def dependency() -> None: calls.append(1) router = MsgspecRouter() @router.post('/value', dependencies=[Depends(dependency)]) - async def endpoint(city: City): + async def endpoint(city: City): # noqa: ANN202 - Test an endpoint without a return annotation. return city app = FastAPI() app.include_router(router) client = TestClient(app, raise_server_exceptions=False) - assert client.post('/value', content=b'{', headers={'Content-Type': 'application/json'}).status_code == 400 - assert client.post('/value', content=b'population=1', headers={'Content-Type': 'text/plain'}).status_code == 415 + assert ( + client.post( + '/value', content=b'{', headers={'Content-Type': 'application/json'} + ).status_code + == 400 + ) + assert ( + client.post( + '/value', content=b'population=1', headers={'Content-Type': 'text/plain'} + ).status_code + == 415 + ) assert calls == [] assert client.post('/value', json={'population': 1}).status_code == 200 assert calls == [1] -def test_body_is_decoded_strictly(): +def test_body_is_decoded_strictly() -> None: router = MsgspecRouter() @router.post('/value') - async def endpoint(city: City): + async def endpoint(city: City): # noqa: ANN202 - Test an endpoint without a return annotation. return city app = FastAPI() @@ -266,12 +310,13 @@ async def endpoint(city: City): @pytest.mark.parametrize('prefix', ['', '/api']) -def test_parameters_are_validated_with_msgspec(prefix): +def test_parameters_are_validated_with_msgspec(prefix: str) -> None: router = MsgspecRouter() @router.get('/value/{item_id}') - async def endpoint(item_id: int, limit: Annotated[int, Query(ge=1, alias='max')] = 10, - ratio: float = 0.5): + async def endpoint( # noqa: ANN202 - Test an endpoint without a return annotation. + item_id: int, limit: Annotated[int, Query(ge=1, alias='max')] = 10, ratio: float = 0.5 + ): return {'item_id': item_id, 'limit': limit, 'ratio': ratio} app = FastAPI() @@ -284,7 +329,11 @@ async def endpoint(item_id: int, limit: Annotated[int, Query(ge=1, alias='max')] app.include_router(router) install_openapi(app, router) client = TestClient(app, raise_server_exceptions=False) - assert client.get(f'{prefix}/value/3?max=2&ratio=1.5').json() == {'item_id': 3, 'limit': 2, 'ratio': 1.5} + assert client.get(f'{prefix}/value/3?max=2&ratio=1.5').json() == { + 'item_id': 3, + 'limit': 2, + 'ratio': 1.5, + } assert client.get(f'{prefix}/value/3').json() == {'item_id': 3, 'limit': 10, 'ratio': 0.5} assert client.get(f'{prefix}/value/x').status_code == 422 assert client.get(f'{prefix}/value/3?max=0').status_code == 422 @@ -295,20 +344,23 @@ async def endpoint(item_id: int, limit: Annotated[int, Query(ge=1, alias='max')] assert next(p for p in parameters if p['name'] == 'max')['schema']['minimum'] == 1 -def test_install_openapi_requires_routers(): +def test_install_openapi_requires_routers() -> None: with pytest.raises(TypeError): install_openapi(FastAPI()) -@pytest.mark.parametrize('annotation,value', [ - *((int, v) for v in ['5', '+5', '05', '1_0', '5.0', '5.5', ' 5 ', 'x']), - *((bool, v) for v in ['true', 'yes', 'off', 'f', '1', 'maybe']), -]) -def test_scalar_query_coercion_matches_fastapi(annotation, value): - def make(router_class): +@pytest.mark.parametrize( + 'annotation,value', + [ + *((int, v) for v in ['5', '+5', '05', '1_0', '5.0', '5.5', ' 5 ', 'x']), + *((bool, v) for v in ['true', 'yes', 'off', 'f', '1', 'maybe']), + ], +) +def test_scalar_query_coercion_matches_fastapi(annotation: Any, value: Any) -> None: + def make(router_class: type[APIRouter]) -> httpx.Response | httpx2.Response: router = router_class() - async def endpoint(q: annotation): + async def endpoint(q: annotation): # noqa: ANN202 - Test an endpoint without a return annotation. return {'q': q} router.add_api_route('/value', endpoint, methods=['GET']) @@ -322,26 +374,31 @@ async def endpoint(q: annotation): assert actual.json() == expected.json() -def strict_content_type_app(level, strict): +def strict_content_type_app(level: str, strict: bool) -> tuple[TestClient, list[int]]: calls = [] - def dependency(): + def dependency() -> None: calls.append(1) - async def endpoint(city: City): + async def endpoint(city: City): # noqa: ANN202 - Test an endpoint without a return annotation. return city app = FastAPI(**({'strict_content_type': strict} if level == 'app' else {})) router = MsgspecRouter(**({'strict_content_type': strict} if level == 'router' else {})) - router.add_api_route('/value', endpoint, methods=['POST'], dependencies=[Depends(dependency)], - **({'strict_content_type': strict} if level == 'route' else {})) + router.add_api_route( + '/value', + endpoint, + methods=['POST'], + dependencies=[Depends(dependency)], + **({'strict_content_type': strict} if level == 'route' else {}), + ) app.include_router(router) return TestClient(app, raise_server_exceptions=False), calls @pytest.mark.parametrize('level', ['app', 'router', 'route']) @pytest.mark.parametrize('strict', [True, False]) -def test_body_without_content_type_follows_strict_content_type(level, strict): +def test_body_without_content_type_follows_strict_content_type(level: str, strict: bool) -> None: client, calls = strict_content_type_app(level, strict) result = client.post('/value', content=b'{"population": 1}') assert 'content-type' not in result.request.headers @@ -351,40 +408,50 @@ def test_body_without_content_type_follows_strict_content_type(level, strict): assert result.status_code == 200 and result.json() == {'population': 1} -def test_route_strict_content_type_overrides_app(): +def test_route_strict_content_type_overrides_app() -> None: app = FastAPI(strict_content_type=False) router = MsgspecRouter() - async def endpoint(city: City): + async def endpoint(city: City): # noqa: ANN202 - Test an endpoint without a return annotation. return city router.add_api_route('/value', endpoint, methods=['POST'], strict_content_type=True) app.include_router(router) - result = TestClient(app, raise_server_exceptions=False).post('/value', content=b'{"population": 1}') + result = TestClient(app, raise_server_exceptions=False).post( + '/value', content=b'{"population": 1}' + ) assert result.status_code == 415 -@pytest.mark.parametrize('content_type', ['', ';', 'text/plain', 'application/x-www-form-urlencoded']) -def test_body_with_non_json_content_type_is_rejected(content_type): +@pytest.mark.parametrize( + 'content_type', ['', ';', 'text/plain', 'application/x-www-form-urlencoded'] +) +def test_body_with_non_json_content_type_is_rejected(content_type: str) -> None: client, calls = strict_content_type_app('app', False) - result = client.post('/value', content=b'{"population": 1}', headers={'Content-Type': content_type}) + result = client.post( + '/value', content=b'{"population": 1}', headers={'Content-Type': content_type} + ) expected = 200 if content_type == '' else 415 assert result.status_code == expected assert calls == ([1] if expected == 200 else []) -@pytest.mark.parametrize('content_type', ['application/json; charset=utf-8', 'application/merge-patch+json']) -def test_body_with_json_content_type_is_accepted(content_type): +@pytest.mark.parametrize( + 'content_type', ['application/json; charset=utf-8', 'application/merge-patch+json'] +) +def test_body_with_json_content_type_is_accepted(content_type: str) -> None: client, _ = strict_content_type_app('app', True) - result = client.post('/value', content=b'{"population": 1}', headers={'Content-Type': content_type}) + result = client.post( + '/value', content=b'{"population": 1}', headers={'Content-Type': content_type} + ) assert result.status_code == 200 and result.json() == {'population': 1} @pytest.mark.parametrize('annotation', [City, list[int], list[Any]]) -def test_deeply_nested_json_body_is_rejected(annotation): +def test_deeply_nested_json_body_is_rejected(annotation: Any) -> None: router = MsgspecRouter() - async def endpoint(value: annotation): + async def endpoint(value: annotation): # noqa: ANN202 - Test an endpoint without a return annotation. return {} router.add_api_route('/value', endpoint, methods=['POST']) @@ -392,7 +459,8 @@ async def endpoint(value: annotation): app.include_router(router) depth = 200_000 result = TestClient(app, raise_server_exceptions=False).post( - '/value', content=b'[' * depth + b']' * depth, headers={'Content-Type': 'application/json'}) + '/value', content=b'[' * depth + b']' * depth, headers={'Content-Type': 'application/json'} + ) assert result.status_code == 400 @@ -404,16 +472,21 @@ class LabeledReading(Reading): label: str = '' -@pytest.mark.parametrize('annotation,value,expected', [ - (float, float('nan'), None), - (float, float('inf'), None), - (dict[str, float], {'a': float('-inf')}, {'a': None}), - (Reading, LabeledReading(float('nan'), 'x'), {'value': None}), - (list[Reading], [Reading(float('nan'))], [{'value': None}]), - (dict[str, float], {1: float('nan')}, {'1': None}), - (dict[int, float], {'1': float('nan')}, {'1': None}), -]) -def test_non_finite_floats_in_typed_response_are_encoded_as_null(annotation, value, expected): +@pytest.mark.parametrize( + 'annotation,value,expected', + [ + (float, float('nan'), None), + (float, float('inf'), None), + (dict[str, float], {'a': float('-inf')}, {'a': None}), + (Reading, LabeledReading(float('nan'), 'x'), {'value': None}), + (list[Reading], [Reading(float('nan'))], [{'value': None}]), + (dict[str, float], {1: float('nan')}, {'1': None}), + (dict[int, float], {'1': float('nan')}, {'1': None}), + ], +) +def test_non_finite_floats_in_typed_response_are_encoded_as_null( + annotation: Any, value: Any, expected: Any +) -> None: # FastAPI with Pydantic also encodes these as null instead of failing. result = response(setup(value, model=annotation)) assert result.status_code == 200 and result.json() == expected @@ -424,18 +497,27 @@ class Measurement(msgspec.Struct): count: int -@pytest.mark.parametrize('annotation,value,message', [ - (Annotated[float, msgspec.Meta(ge=0)], float('nan'), 'Expected `float` >= 0.0'), - (Annotated[float, msgspec.Meta(ge=0)], float('-inf'), 'Expected `float` >= 0.0'), - (Measurement, {'value': float('nan'), 'count': 'x'}, 'Expected `int`, got `str` - at `$.count`'), - (Reading, {'value': 'x'}, 'Expected `float`, got `str` - at `$.value`'), - (float, 'nan', 'Expected `float`, got `str`'), - (float, msgspec.Raw(b'"x"'), 'Expected `float`, got `str`'), -]) -def test_invalid_typed_response_is_still_rejected(annotation, value, message): +@pytest.mark.parametrize( + 'annotation,value,message', + [ + (Annotated[float, msgspec.Meta(ge=0)], float('nan'), 'Expected `float` >= 0.0'), + (Annotated[float, msgspec.Meta(ge=0)], float('-inf'), 'Expected `float` >= 0.0'), + ( + Measurement, + {'value': float('nan'), 'count': 'x'}, + 'Expected `int`, got `str` - at `$.count`', + ), + (Reading, {'value': 'x'}, 'Expected `float`, got `str` - at `$.value`'), + (float, 'nan', 'Expected `float`, got `str`'), + (float, msgspec.Raw(b'"x"'), 'Expected `float`, got `str`'), + ], +) +def test_invalid_typed_response_is_still_rejected( + annotation: Any, value: Any, message: str +) -> None: router = MsgspecRouter() - async def endpoint(): + async def endpoint(): # noqa: ANN202 - Test an endpoint without a return annotation. return value router.add_api_route('/value', endpoint, methods=['GET'], response_model=annotation) @@ -446,10 +528,10 @@ async def endpoint(): assert info.value.errors()[0]['msg'] == message -def auth_app(): +def auth_app() -> tuple[TestClient, list[str | None]]: calls = [] - def authenticate(token: Annotated[str | None, Header()] = None): + def authenticate(token: Annotated[str | None, Header()] = None) -> None: calls.append(token) if token != 'secret': raise HTTPException(401, 'Not authenticated') @@ -457,7 +539,7 @@ def authenticate(token: Annotated[str | None, Header()] = None): router = MsgspecRouter() @router.post('/value', dependencies=[Depends(authenticate)]) - async def endpoint(city: City): + async def endpoint(city: City): # noqa: ANN202 - Test an endpoint without a return annotation. return city app = FastAPI() @@ -466,7 +548,9 @@ async def endpoint(city: City): @pytest.mark.parametrize('body', [{'population': -1}, {}, None]) -def test_body_validation_errors_are_reported_after_dependencies(body): +def test_body_validation_errors_are_reported_after_dependencies( + body: dict[str, Any] | None, +) -> None: # As in FastAPI, a failing dependency such as authentication takes precedence over # a body that does not match the schema. client, calls = auth_app() @@ -478,14 +562,19 @@ def test_body_validation_errors_are_reported_after_dependencies(body): assert result.json()['detail'][0]['loc'] == ['body'] -def test_malformed_body_is_still_rejected_before_dependencies(): +def test_malformed_body_is_still_rejected_before_dependencies() -> None: client, calls = auth_app() - assert client.post('/value', content=b'{', headers={'Content-Type': 'application/json'}).status_code == 400 + assert ( + client.post( + '/value', content=b'{', headers={'Content-Type': 'application/json'} + ).status_code + == 400 + ) assert calls == [] @pytest.mark.parametrize('router_class', [APIRouter, MsgspecRouter]) -def test_none_return_annotation_declares_no_response_model(router_class): +def test_none_return_annotation_declares_no_response_model(router_class: type[APIRouter]) -> None: router = router_class() @router.delete('/value', status_code=204) @@ -506,11 +595,11 @@ async def get() -> None: assert responses['200']['content']['application/json']['schema'] == {} -def test_form_and_file_parameters_are_left_to_fastapi(): +def test_form_and_file_parameters_are_left_to_fastapi() -> None: router = MsgspecRouter() @router.post('/value') - async def endpoint(name: Annotated[str, Form()], data: Annotated[bytes, File()]): + async def endpoint(name: Annotated[str, Form()], data: Annotated[bytes, File()]): # noqa: ANN202 - Test an endpoint without a return annotation. return {'name': name, 'size': len(data)} app = FastAPI() @@ -519,10 +608,10 @@ async def endpoint(name: Annotated[str, Form()], data: Annotated[bytes, File()]) assert result.status_code == 200 and result.json() == {'name': 'x', 'size': 3} -def test_struct_form_parameter_is_rejected(): +def test_struct_form_parameter_is_rejected() -> None: router = MsgspecRouter() - async def endpoint(city: Annotated[City, Form()]): + async def endpoint(city: Annotated[City, Form()]): # noqa: ANN202 - Test an endpoint without a return annotation. return city with pytest.raises(TypeError, match='Form or File'): @@ -530,10 +619,12 @@ async def endpoint(city: Annotated[City, Form()]): @pytest.mark.parametrize('annotation,value', [(str, '\ud800'), (Any, {None: 1})]) -def test_unencodable_typed_response_raises_response_validation_error(annotation, value): +def test_unencodable_typed_response_raises_response_validation_error( + annotation: Any, value: Any +) -> None: router = MsgspecRouter() - async def endpoint(): + async def endpoint(): # noqa: ANN202 - Test an endpoint without a return annotation. return value router.add_api_route('/value', endpoint, methods=['GET'], response_model=annotation)