Skip to content
1 change: 1 addition & 0 deletions falcon/media/validators/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import jsonschema
from . import jsonschema_rs
128 changes: 128 additions & 0 deletions falcon/media/validators/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
from __future__ import annotations

from abc import abstractmethod
from functools import wraps
from inspect import iscoroutinefunction
from typing import Any, Callable, Optional, Tuple, Type, TypeVar, Union

import falcon


class Validator:
"""Base validator class."""

exceptions: Union[Tuple[Type[Exception], ...], Type[Exception]]
"""The exceptions raised by the validation library"""

@classmethod
@abstractmethod
def from_schema(cls, schema: Any) -> Validator:
"""Construct the class from a schema object."""

@abstractmethod
def validate(self, media: Any) -> None:
"""Validate the input media."""

@abstractmethod
def get_exception_message(self, exception: Exception) -> Optional[str]:
"""Return a message from an exception."""


_T = TypeVar('_T')


def validator_factory(
validator: Type[Validator], req_schema: Any, resp_schema: Any, is_async: bool
) -> Callable[[_T], _T]:
"""Create a validator decorator for that uses the specified ``Validator`` class.

Args:
validator (Type[Validator]): The validator class.
req_schema (Any): The schema used in the request body. Type will depend on
what is accepted by ``Validator.from_schema``.
When ``None`` validation will be skipped.
resp_schema (Any): The schema used in the response body. Type will depend
on what is accepted by ``Validator.from_schema``.
When ``None`` validation will be skipped.
is_async (bool): Set to ``True`` to force use of the async validator.
"""

def decorator(func: _T) -> _T:

Check warning on line 50 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L50

Added line #L50 was not covered by tests
if iscoroutinefunction(func) or is_async:
return _validate_async(validator, func, req_schema, resp_schema)

Check warning on line 52 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L52

Added line #L52 was not covered by tests

return _validate(validator, func, req_schema, resp_schema)

Check warning on line 54 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L54

Added line #L54 was not covered by tests

return decorator

Check warning on line 56 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L56

Added line #L56 was not covered by tests


def _validate(
validator: Type[Validator], func, req_schema: Any, resp_schema: Any
) -> Any:
req_validator = None if req_schema is None else validator.from_schema(req_schema)
resp_validator = None if resp_schema is None else validator.from_schema(resp_schema)

Check warning on line 63 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L62-L63

Added lines #L62 - L63 were not covered by tests

@wraps(func)

Check warning on line 65 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L65

Added line #L65 was not covered by tests
def wrapper(self, req, resp, *args, **kwargs):
if req_validator is not None:
try:
req_validator.validate(req.media)
except req_validator.exceptions as ex:
raise falcon.MediaValidationError(

Check warning on line 71 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L68-L71

Added lines #L68 - L71 were not covered by tests
title='Request data failed validation',
description=req_validator.get_exception_message(ex),
) from ex

result = func(self, req, resp, *args, **kwargs)

Check warning on line 76 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L76

Added line #L76 was not covered by tests

if resp_validator is not None:
try:
resp_validator.validate(resp.media)
except resp_validator.exceptions as ex:
raise falcon.HTTPInternalServerError(

Check warning on line 82 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L79-L82

Added lines #L79 - L82 were not covered by tests
title='Response data failed validation'
# Do not return 'e.message' in the response to
# prevent info about possible internal response
# formatting bugs from leaking out to users.
) from ex

return result

Check warning on line 89 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L89

Added line #L89 was not covered by tests

return wrapper

Check warning on line 91 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L91

Added line #L91 was not covered by tests


def _validate_async(
validator: Type[Validator], func, req_schema: Any, resp_schema: Any
) -> Any:
req_validator = None if req_schema is None else validator.from_schema(req_schema)
resp_validator = None if resp_schema is None else validator.from_schema(resp_schema)

Check warning on line 98 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L97-L98

Added lines #L97 - L98 were not covered by tests

@wraps(func)

Check warning on line 100 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L100

Added line #L100 was not covered by tests
async def wrapper(self, req, resp, *args, **kwargs):
if req_validator is not None:
m = await req.get_media()

Check warning on line 103 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L103

Added line #L103 was not covered by tests

try:
req_validator.validate(m)
except req_validator.exceptions as ex:
raise falcon.MediaValidationError(

Check warning on line 108 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L105-L108

Added lines #L105 - L108 were not covered by tests
title='Request data failed validation',
description=req_validator.get_exception_message(ex),
) from ex

result = await func(self, req, resp, *args, **kwargs)

Check warning on line 113 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L113

Added line #L113 was not covered by tests

if resp_validator is not None:
try:
resp_validator.validate(resp.media)
except resp_validator.exceptions as ex:
raise falcon.HTTPInternalServerError(

Check warning on line 119 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L116-L119

Added lines #L116 - L119 were not covered by tests
title='Response data failed validation'
# Do not return 'e.message' in the response to
# prevent info about possible internal response
# formatting bugs from leaking out to users.
) from ex

return result

Check warning on line 126 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L126

Added line #L126 was not covered by tests

return wrapper

Check warning on line 128 in falcon/media/validators/base.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/base.py#L128

Added line #L128 was not covered by tests
107 changes: 28 additions & 79 deletions falcon/media/validators/jsonschema.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
from functools import wraps
from inspect import iscoroutinefunction
from __future__ import annotations

import falcon
from typing import Any, Optional, TYPE_CHECKING

from . import base as _base

try:
import jsonschema
except ImportError: # pragma: nocover
pass


def validate(req_schema=None, resp_schema=None, is_async=False):
def validate(req_schema: Any = None, resp_schema: Any = None, is_async: bool = False):
"""Validate ``req.media`` using JSON Schema.

This decorator provides standard JSON Schema validation via the
Expand Down Expand Up @@ -99,78 +100,26 @@

"""

def decorator(func):
if iscoroutinefunction(func) or is_async:
return _validate_async(func, req_schema, resp_schema)

return _validate(func, req_schema, resp_schema)

return decorator


def _validate(func, req_schema=None, resp_schema=None):
@wraps(func)
def wrapper(self, req, resp, *args, **kwargs):
if req_schema is not None:
try:
jsonschema.validate(
req.media, req_schema, format_checker=jsonschema.FormatChecker()
)
except jsonschema.ValidationError as ex:
raise falcon.MediaValidationError(
title='Request data failed validation', description=ex.message
) from ex

result = func(self, req, resp, *args, **kwargs)

if resp_schema is not None:
try:
jsonschema.validate(
resp.media, resp_schema, format_checker=jsonschema.FormatChecker()
)
except jsonschema.ValidationError as ex:
raise falcon.HTTPInternalServerError(
title='Response data failed validation'
# Do not return 'e.message' in the response to
# prevent info about possible internal response
# formatting bugs from leaking out to users.
) from ex

return result

return wrapper


def _validate_async(func, req_schema=None, resp_schema=None):
@wraps(func)
async def wrapper(self, req, resp, *args, **kwargs):
if req_schema is not None:
m = await req.get_media()

try:
jsonschema.validate(
m, req_schema, format_checker=jsonschema.FormatChecker()
)
except jsonschema.ValidationError as ex:
raise falcon.MediaValidationError(
title='Request data failed validation', description=ex.message
) from ex

result = await func(self, req, resp, *args, **kwargs)

if resp_schema is not None:
try:
jsonschema.validate(
resp.media, resp_schema, format_checker=jsonschema.FormatChecker()
)
except jsonschema.ValidationError as ex:
raise falcon.HTTPInternalServerError(
title='Response data failed validation'
# Do not return 'e.message' in the response to
# prevent info about possible internal response
# formatting bugs from leaking out to users.
) from ex

return result

return wrapper
return _base.validator_factory(

Check warning on line 103 in falcon/media/validators/jsonschema.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/jsonschema.py#L103

Added line #L103 was not covered by tests
JsonSchemaValidator, req_schema, resp_schema, is_async
)


class JsonSchemaValidator(_base.Validator):
def __init__(self, schema: Any) -> None:
self.schema = schema
self.exceptions = jsonschema.ValidationError

Check warning on line 111 in falcon/media/validators/jsonschema.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/jsonschema.py#L110-L111

Added lines #L110 - L111 were not covered by tests

@classmethod
def from_schema(cls, schema: Any) -> JsonSchemaValidator:
return cls(schema)

Check warning on line 115 in falcon/media/validators/jsonschema.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/jsonschema.py#L115

Added line #L115 was not covered by tests

def validate(self, media: Any) -> None:
jsonschema.validate(

Check warning on line 118 in falcon/media/validators/jsonschema.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/jsonschema.py#L118

Added line #L118 was not covered by tests
media, self.schema, format_checker=jsonschema.FormatChecker()
)

def get_exception_message(self, exception: Exception) -> Optional[str]:
if TYPE_CHECKING:
assert isinstance(exception, jsonschema.ValidationError)
return exception.message

Check warning on line 125 in falcon/media/validators/jsonschema.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/jsonschema.py#L125

Added line #L125 was not covered by tests
127 changes: 127 additions & 0 deletions falcon/media/validators/jsonschema_rs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
from __future__ import annotations

from typing import Any, Optional, TYPE_CHECKING

from . import base as _base

try:
import jsonschema_rs
except ImportError: # pragma: nocover
pass


def validate(req_schema: Any = None, resp_schema: Any = None, is_async: bool = False):
"""Validate ``req.media`` using JSON Schema.

This decorator provides standard JSON Schema validation via the
``jsonschema_rs`` package available from PyPI.

In the case of failed request media validation, an instance of
:class:`~falcon.MediaValidationError` is raised by the decorator. By
default, this error is rendered as a 400 (:class:`~falcon.HTTPBadRequest`)
response with the ``title`` and ``description`` attributes explaining the
validation failure, but this behavior can be modified by adding a
custom error :func:`handler <falcon.App.add_error_handler>` for
:class:`~falcon.MediaValidationError`.

Note:
The ``jsonschema_rs`` package must be installed separately in order to use
this decorator, as Falcon does not install it by default.

See `jsonschema_rs PyPi <https://pypi.org/project/jsonschema-rs/>`_ for more
information on defining a compatible dictionary.

Keyword Args:
req_schema (dict or str): A dictionary that follows the JSON
Schema specification. The request will be validated against this
schema.
Can be also a json string that will be loaded by the jsonschema_rs library
resp_schema (dict or str): A dictionary that follows the JSON
Schema specification. The response will be validated against this
schema.
Can be also a json string that will be loaded by the jsonschema_rs library
is_async (bool): Set to ``True`` for ASGI apps to provide a hint that
the decorated responder is a coroutine function (i.e., that it
is defined with ``async def``) or that it returns an awaitable
coroutine object.

Normally, when the function source is declared using ``async def``,
the resulting function object is flagged to indicate it returns a
coroutine when invoked, and this can be automatically detected.
However, it is possible to use a regular function to return an
awaitable coroutine object, in which case a hint is required to let
the framework know what to expect. Also, a hint is always required
when using a cythonized coroutine function, since Cython does not
flag them in a way that can be detected in advance, even when the
function is declared using ``async def``.

Example:

.. tabs::

.. tab:: WSGI

.. code:: python

from falcon.media.validators import jsonschema_rs

# -- snip --

@jsonschema_rs.validate(my_post_schema)
def on_post(self, req, resp):

# -- snip --

.. tab:: ASGI

.. code:: python

from falcon.media.validators import jsonschema_rs

# -- snip --

@jsonschema_rs.validate(my_post_schema)
async def on_post(self, req, resp):

# -- snip --

.. tab:: ASGI (Cythonized App)

.. code:: python

from falcon.media.validators import jsonschema_rs

# -- snip --

@jsonschema_rs.validate(my_post_schema, is_async=True)
async def on_post(self, req, resp):

# -- snip --

"""

return _base.validator_factory(

Check warning on line 103 in falcon/media/validators/jsonschema_rs.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/jsonschema_rs.py#L103

Added line #L103 was not covered by tests
JsonSchemaRsValidator, req_schema, resp_schema, is_async
)


class JsonSchemaRsValidator(_base.Validator):
def __init__(self, schema: Any) -> None:
self.schema = schema

Check warning on line 110 in falcon/media/validators/jsonschema_rs.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/jsonschema_rs.py#L110

Added line #L110 was not covered by tests
if isinstance(schema, str):
self.validator = jsonschema_rs.JSONSchema.from_str(schema)

Check warning on line 112 in falcon/media/validators/jsonschema_rs.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/jsonschema_rs.py#L112

Added line #L112 was not covered by tests
else:
self.validator = jsonschema_rs.JSONSchema(schema)
self.exceptions = jsonschema_rs.ValidationError

Check warning on line 115 in falcon/media/validators/jsonschema_rs.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/jsonschema_rs.py#L114-L115

Added lines #L114 - L115 were not covered by tests

@classmethod
def from_schema(cls, schema: Any) -> JsonSchemaRsValidator:
return cls(schema)

Check warning on line 119 in falcon/media/validators/jsonschema_rs.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/jsonschema_rs.py#L119

Added line #L119 was not covered by tests

def validate(self, media: Any) -> None:
self.validator.validate(media)

Check warning on line 122 in falcon/media/validators/jsonschema_rs.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/jsonschema_rs.py#L122

Added line #L122 was not covered by tests

def get_exception_message(self, exception: Exception) -> Optional[str]:
if TYPE_CHECKING:
assert isinstance(exception, jsonschema_rs.ValidationError)
return exception.message

Check warning on line 127 in falcon/media/validators/jsonschema_rs.py

View check run for this annotation

Codecov / codecov/patch

falcon/media/validators/jsonschema_rs.py#L127

Added line #L127 was not covered by tests
Loading