Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: CI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: Python ${{ matrix.python }}, ${{ matrix.bounds }} dependency bounds
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python: ["3.11", "3.12", "3.13", "3.14"]
bounds: [latest, lowest]
env:
PYTHON: ${{ matrix.python }}
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
run: uv sync --python "$PYTHON" --group test
- name: Install the lowest supported FastAPI and msgspec
if: matrix.bounds == 'lowest'
run: uv pip install "fastapi==0.142.2" "msgspec==0.22.0"
- name: Show versions
run: uv run --no-sync python -c "import sys, fastapi, msgspec, pydantic, starlette; print(sys.version); print(fastapi.__version__, msgspec.__version__, pydantic.__version__, starlette.__version__)"
- name: Check the lowest versions
if: matrix.bounds == 'lowest'
# `uv run` without --no-sync would reinstall the locked versions.
run: uv run --no-sync python -c "import fastapi, msgspec; assert (fastapi.__version__, msgspec.__version__) == ('0.142.2', '0.22.0')"
- name: Test
run: uv run --no-sync pytest

build:
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: Build
run: uv build
- name: Check the distributions
run: uvx twine@7.0.0 check --strict dist/*
32 changes: 22 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,27 @@ install_openapi(app, router)
## What it does

- A msgspec body parameter (a Struct or another type msgspec can decode) is decoded from
the raw request bytes by a `msgspec.json.Decoder` built once at route registration.
A non-JSON content type returns 415, malformed JSON returns 400, and a missing or
invalid body returns 422. Bodies are decoded in msgspec strict mode: for example, the
JSON string `"1"` is not accepted for an `int` field, unlike FastAPI with Pydantic.
the raw request bytes by a `msgspec.json.Decoder` built once at route registration. A
body with a non-JSON content type returns 415, malformed JSON or JSON nested too deeply
returns 400, and a missing or invalid body returns 422. The 415 and 400 responses are
sent before dependencies run; a 422 is sent after them, as in FastAPI, so a failing
dependency such as authentication takes precedence. Bodies are decoded in msgspec
strict mode: for example, the JSON string `"1"` is not accepted for an `int` field,
unlike FastAPI with Pydantic.
- A body without a content type follows FastAPI's `strict_content_type` setting: it is
rejected with 415 by default and parsed as JSON when the setting is `False`. FastAPI
returns 422 in the first case.
- A msgspec response type, from the return annotation or `response_model`, is encoded
with msgspec. An instance of exactly the declared Struct class is encoded without
revalidation, as FastAPI does for a Pydantic `response_model`; other values are
validated against the declared type. Nested values and type parameters of a trusted
instance are not checked.
- Responses of routes without a return annotation or `response_model` are also encoded
with msgspec, so the output can differ from FastAPI's `jsonable_encoder` (for example,
`bytes` are base64-encoded and `Decimal` is encoded as a string).
instance are not checked. A NaN or infinite value in a `float` field is encoded as
`null` unless a constraint rejects it. In a `Decimal` field it is encoded as a string,
while FastAPI with Pydantic rejects non-finite `Decimal` values.
- Responses of routes without a return annotation or `response_model`, or with
`-> None`, are also encoded with msgspec, so the output can differ from FastAPI's
`jsonable_encoder` (for example, `bytes` are base64-encoded and `Decimal` is encoded as
a string).
- Path, query, header and cookie parameters keep FastAPI extraction, aliases and
defaults, but are converted with `msgspec.convert(strict=False)` instead of Pydantic,
so coercion differs for some inputs (for example, `" 1.5"` is rejected for `float`
Expand All @@ -68,9 +77,12 @@ install_openapi(app, router)

- Only one msgspec body parameter per route, and it cannot be combined with other
FastAPI body fields. Only JSON bodies are supported: `application/json`,
`application/*+json`, or a request without a content type.
`application/*+json`, or a request without a content type when `strict_content_type`
is `False`. `Form()` and `File()` parameters are left to FastAPI.
- When both the parameters and the body are invalid, only the parameter errors are
returned; FastAPI returns both.
- Structs, alone or inside containers, are not supported as path, query, header or
cookie parameters.
cookie parameters, or as `Form()` and `File()` parameters.
- `response_model_include`, `response_model_exclude`, `response_model_exclude_*` and
`response_model_by_alias=False` are rejected for typed msgspec responses.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Source = "https://github.com/Siyet/msgspec-fastapi"
Issues = "https://github.com/Siyet/msgspec-fastapi/issues"

[dependency-groups]
test = ["pytest", "httpx", "httpx2"]
test = ["pytest", "httpx", "httpx2", "python-multipart"]

[tool.hatch.build.targets.wheel]
packages = ["src/msgspec_fastapi"]
Expand Down
73 changes: 61 additions & 12 deletions src/msgspec_fastapi/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from fastapi.datastructures import DefaultPlaceholder
from fastapi.exceptions import RequestValidationError, ResponseValidationError
from fastapi.openapi.utils import validation_error_definition, validation_error_response_definition
from fastapi.params import Body, Depends, Param
from fastapi.params import Body, Depends, Form, Param
from fastapi.routing import APIRoute, _effective_route_context_var, iter_route_contexts
from starlette.concurrency import run_in_threadpool

Expand Down Expand Up @@ -85,16 +85,23 @@ def _hints(endpoint):
return get_type_hints(target, include_extras=True)


def _parse_body(request, raw, config):
def _parse_body(request, raw, config, strict_content_type):
decoder, default, embedded = config
if not raw:
if default is inspect.Signature.empty:
raise RequestValidationError([{'loc': ('body',), 'msg': 'Field required', 'type': 'missing'}])
# Raised after dependencies run, as in FastAPI.
return RequestValidationError([{'loc': ('body',), 'msg': 'Field required', 'type': 'missing'}])
return deepcopy(default)
media_type = request.headers.get('content-type', '').split(';')[0].strip().lower()
if media_type and media_type != 'application/json' and not (
media_type.startswith('application/') and media_type.endswith('+json')
):
content_type = request.headers.get('content-type')
if content_type:
media_type = content_type.split(';')[0].strip().lower()
if media_type != 'application/json' and not (
media_type.startswith('application/') and media_type.endswith('+json')
):
raise HTTPException(415, 'Expected a JSON request body')
elif strict_content_type:
# Like FastAPI's strict_content_type: a body without a content type can be sent
# cross-origin without a CORS preflight, so it is not parsed as JSON.
raise HTTPException(415, 'Expected a JSON request body')
try:
value = decoder.decode(raw)
Expand All @@ -104,12 +111,38 @@ def _parse_body(request, raw, config):
msgspec.json.decode(raw)
except (msgspec.DecodeError, UnicodeError) as syntax:
raise HTTPException(400, f'Malformed JSON body: {syntax}') from syntax
raise RequestValidationError([{'loc': ('body',), 'msg': str(exc), 'type': 'value_error.msgspec'}]) from exc
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'}])
except (msgspec.DecodeError, UnicodeError) as exc:
raise HTTPException(400, f'Malformed JSON body: {exc}') from exc
except RecursionError as exc:
raise HTTPException(400, 'JSON body is nested too deeply') from exc
return value.value if embedded else value


def _revalidate_without_json(result, output_type, error):
"""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.
FastAPI with Pydantic encodes them as null in a response, so validate the Python
values instead, with object keys converted to strings. A validation error from this
check is reported, because the one from the JSON round trip can point at a
non-finite float instead of the actual problem. Values that cannot be encoded as
JSON are not revalidated.
"""
if isinstance(error, msgspec.ValidationError):
try:
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


class MsgspecRoute(APIRoute):
def get_route_handler(self):
context = _effective_route_context_var.get()
Expand All @@ -122,9 +155,14 @@ def get_route_handler(self):
if route.body_field is not None:
raise TypeError('Other FastAPI body fields cannot be combined with a msgspec body')

strict_content_type = route.strict_content_type
if isinstance(strict_content_type, DefaultPlaceholder):
strict_content_type = strict_content_type.value

async def handle(request):
# Invalid JSON must be rejected before dependency side effects.
request.scope['_msgspec_body_value'] = _parse_body(request, await request.body(), config)
# 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)
return await handler(request)
return handle

Expand Down Expand Up @@ -194,13 +232,17 @@ def add_api_route(self, path, endpoint, **kwargs):
override = kwargs.get('route_class_override')
if override is not None and not issubclass(override, MsgspecRoute):
raise TypeError('route_class_override must extend MsgspecRoute')
# Included routes already have a prepared signature and response metadata.
# Routes copied from another MsgspecRouter with add_api_route already have a
# prepared signature and response metadata.
if getattr(endpoint, '_msgspec_prepared', False):
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()]
return_type = hints.get('return', signature.return_annotation)
if return_type is type(None):
# As in FastAPI, `-> None` declares no response model.
return_type = None
explicit = kwargs.get('response_model', inspect.Signature.empty)
inferred = explicit is inspect.Signature.empty or isinstance(explicit, DefaultPlaceholder)
output_type = return_type if inferred else explicit
Expand Down Expand Up @@ -241,6 +283,11 @@ def add_api_route(self, path, endpoint, **kwargs):
metadata = _metadata(parameter, parameter.annotation)
if any(isinstance(x, Depends) for x in metadata):
continue
# Form and File parameters are left to FastAPI.
if any(isinstance(x, Form) for x in metadata):
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)
if _body_type(parameter.annotation) and not any(isinstance(x, Param) for x in metadata):
is_body = True
Expand Down Expand Up @@ -359,6 +406,8 @@ async def wrapper(**values):
if remove_request:
values.pop(request_name)
value = request.scope['_msgspec_body_value']
if isinstance(value, RequestValidationError):
raise value
values[body.name] = value
if is_async:
result = await endpoint(**values)
Expand All @@ -375,7 +424,7 @@ async def wrapper(**values):
# Typed decoding validates fields and Meta, even for existing Structs.
result = output_decoder.decode(output_encoder.encode(result))
except (TypeError, ValueError, OverflowError) as exc:
raise ResponseValidationError([{'loc': ('response',), 'msg': str(exc), 'type': 'value_error.msgspec'}]) from 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,
Expand Down
Loading
Loading