Msgspec Integration#

This recipe illustrates how the popular msgspec data serialization and validation library can be used with Falcon.

Media handlers#

msgspec can be used for JSON serialization by simply instantiating JSONHandler with the msgspec.json.decode() and msgspec.json.encode() functions:

import msgspec

import falcon.media

json_handler = falcon.media.JSONHandler(
    dumps=msgspec.json.encode,
    loads=msgspec.json.decode,
)

Note

It would be more efficient to use preconstructed msgspec.json.Decoder and msgspec.json.Encoder instead of initializing them every time via msgspec.json.decode() and msgspec.json.encode(), respectively. This optimization is left as an exercise for the reader.

Attention

As seen above, plugging msgspec into JSONHandler may look extremely straightforward. We have, however, omitted an important caveat when it comes to error handling – see a more detailed discussion in the below chapter: Error handling.

Using msgspec for handling MessagePack (or YAML) media is slightly more involved, as we need to implement the BaseHandler interface:

from typing import Optional

from msgspec import msgpack

from falcon import media
from falcon.typing import ReadableIO


class MsgspecMessagePackHandler(media.BaseHandler):
    def deserialize(
        self,
        stream: ReadableIO,
        content_type: Optional[str],
        content_length: Optional[int],
    ) -> object:
        return msgpack.decode(stream.read())

    def serialize(self, media: object, content_type: str) -> bytes:
        return msgpack.encode(media)


msgpack_handler = MsgspecMessagePackHandler()

We can now use these handlers for request and response media (see also: Replacing the Default Handlers).

Media validation#

Falcon currently only provides optional media validation using JSON Schema, so we will implement validation separately using process_resource middleware. To that end, let us assume that resources can expose schema attributes based on the HTTP verb: PATCH_SCHEMA, POST_SCHEMA, etc, pointing to the msgspec.Struct in question. We will inject the validated object into params:

import msgspec

from falcon import Request
from falcon import Response


class MsgspecMiddleware:
    def process_resource(
        self, req: Request, resp: Response, resource: object, params: dict
    ) -> None:
        if schema := getattr(resource, f'{req.method}_SCHEMA', None):
            param = schema.__name__.lower()
            params[param] = msgspec.convert(req.get_media(), schema)

Here, the name of the injected parameter is simply a lowercase version of the schema’s class name. We could instead store this name in a constant on the resource, or on the schema class.

Note that while the above middleware is only suitable for synchronous (WSGI) Falcon applications, you could easily transform this method to async, or even implement an async version in parallel on the same middleware as process_request_async(...). Just do not forget to await req.get_media()!

The astute reader may also notice that the process can be optimized even further, as msgspec affords combined deserialization and validation in a single call to decode() without going via an intermediate Python object (in this case, effectively req.media). However, if one wants to support more than just a single request media handler, all the media functionality would need to be reimplemented almost from scratch.

We are looking into providing better affordances for this scenario in the framework itself (see also #2202 on GitHub), as well as offering centralized media validation connected to the application’s OpenAPI specification. Your input is extremely valuable for us, so do not hesitate to get in touch, and share your vision!

Error handling#

Schema validation can fail, and the resulting exception would unexpectedly bubble up as a generic HTTP 500 error. We can do better! Skimming through msgspec's docs, we find out that this case is represented by msgspec.ValidationError. We could either create an error hander that reraises this exception as MediaValidationError, or just use a try.. except clause, and reraise directly inside middleware.

Our customized JSONHandler has another issue: unlike the stdlib’s json or the majority of other JSON libraries, msgspec.DecodeError is not a subclass of ValueError:

>>> import msgspec
>>> issubclass(msgspec.DecodeError, ValueError)
False

This discrepancy can be worked around using a wrapper around msgspec.json.decode() that reraises a ValueError from msgspec.DecodeError. Furthermore, the problem has been reported upstream, and received positive feedback from the maintainer, so hopefully it could get resolved in the near future.

Complete recipe#

Finally, we combine these snippets into a note taking application:

from datetime import datetime
from datetime import timezone
from functools import partial
from http import HTTPStatus
from typing import Annotated, Any
import uuid

import msgspec

import falcon
from falcon import Request
from falcon import Response
from falcon.media import JSONHandler


def _utc_now() -> datetime:
    return datetime.now(timezone.utc)


class Note(msgspec.Struct):
    text: Annotated[str, msgspec.Meta(max_length=256)]
    noteid: uuid.UUID = msgspec.field(default_factory=uuid.uuid4)
    created: datetime = msgspec.field(
        default_factory=partial(datetime.now, timezone.utc)
    )


class NoteResource:
    POST_SCHEMA = Note

    def __init__(self) -> None:
        # NOTE: In a real-world app, you would want to use persistent storage.
        self._store = {}

    def on_get_note(self, req: Request, resp: Response, noteid: uuid.UUID) -> None:
        resp.media = self._store.get(str(noteid))
        if not resp.media:
            raise falcon.HTTPNotFound(
                description=f'Note with {noteid=} is not in the store.'
            )

    def on_delete_note(self, req: Request, resp: Response, noteid: uuid.UUID) -> None:
        self._store.pop(str(noteid), None)
        resp.status = HTTPStatus.NO_CONTENT

    def on_get(self, req: Request, resp: Response) -> None:
        resp.media = self._store

    def on_post(self, req: Request, resp: Response, note: Note) -> None:
        self._store[str(note.noteid)] = note
        resp.location = f'{req.path}/{note.noteid}'
        resp.media = note
        resp.status = HTTPStatus.CREATED


class MsgspecMiddleware:
    def process_resource(
        self, req: Request, resp: Response, resource: object, params: dict[str, Any]
    ) -> None:
        if schema := getattr(resource, f'{req.method}_SCHEMA', None):
            param = schema.__name__.lower()
            params[param] = msgspec.convert(req.get_media(), schema)


def _msgspec_loads(content: str) -> Any:
    try:
        return msgspec.json.decode(content)
    except msgspec.DecodeError as ex:
        raise falcon.MediaMalformedError(falcon.MEDIA_JSON) from ex


def _handle_validation_error(
    req: Request, resp: Response, ex: msgspec.ValidationError, params: dict[str, Any]
) -> None:
    raise falcon.HTTPUnprocessableEntity(description=str(ex))


def create_app() -> falcon.App:
    app = falcon.App(middleware=[MsgspecMiddleware()])
    app.add_error_handler(msgspec.ValidationError, _handle_validation_error)

    json_handler = JSONHandler(
        dumps=msgspec.json.encode,
        loads=_msgspec_loads,
    )
    app.req_options.media_handlers[falcon.MEDIA_JSON] = json_handler
    app.resp_options.media_handlers[falcon.MEDIA_JSON] = json_handler

    notes = NoteResource()
    app.add_route('/notes', notes)
    app.add_route('/notes/{noteid:uuid}', notes, suffix='note')

    return app


application = create_app()

We can now POST a new note, view the whole collection, or just GET an individual note. (MessagePack support was omitted for brevity.)