Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: generalize validators and support jsonschema-rs #2225

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
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(
CaselIT marked this conversation as resolved.
Show resolved Hide resolved
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
Loading