Skip to content

Commit

Permalink
Support async invariants (#931)
Browse files Browse the repository at this point in the history
  • Loading branch information
vangheem authored Feb 28, 2020
1 parent cb44ec7 commit 1212c6f
Show file tree
Hide file tree
Showing 6 changed files with 129 additions and 23 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ CHANGELOG
5.3.31 (unreleased)
-------------------

- Nothing changed yet.
- Be able to have async schema invariants
[vangheem]


5.3.30 (2020-02-24)
Expand Down
7 changes: 6 additions & 1 deletion guillotina/auth/extractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,12 @@ async def extract_token(self, value=None):
if header_auth is not None:
schema, _, encoded_token = header_auth.partition(" ")
if schema.lower() == "basic":
token = base64.b64decode(encoded_token).decode("utf-8")
try:
token = base64.b64decode(encoded_token).decode("utf-8")
except Exception: # pragma: no cover
# could be unicode, could be binascii generic,
# should just be ignored if we can't decode
return
userid, _, password = token.partition(":")
return {"type": "basic", "id": userid.strip(), "token": password.strip()}

Expand Down
62 changes: 44 additions & 18 deletions guillotina/json/deserialize_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from guillotina.component import query_utility
from guillotina.content import get_all_behaviors
from guillotina.content import get_cached_factory
from guillotina.db.orm.interfaces import IBaseObject
from guillotina.db.transaction import _EMPTY
from guillotina.directives import merged_tagged_value_dict
from guillotina.directives import write_permission
Expand All @@ -19,10 +20,17 @@
from guillotina.interfaces import IResource
from guillotina.interfaces import IResourceDeserializeFromJson
from guillotina.interfaces import RESERVED_ATTRS
from guillotina.interfaces.misc import IRequest
from guillotina.json.utils import validate_invariants
from guillotina.schema import get_fields
from guillotina.schema.exceptions import ValidationError
from guillotina.schema.interfaces import IField
from guillotina.utils import apply_coroutine
from guillotina.utils import get_security_policy
from typing import Any
from typing import Dict
from typing import List
from typing import Type
from zope.interface import Interface

import asyncio
Expand All @@ -34,14 +42,20 @@

@configure.adapter(for_=(IResource, Interface), provides=IResourceDeserializeFromJson)
class DeserializeFromJson(object):
def __init__(self, context, request):
def __init__(self, context: IBaseObject, request: IRequest):
self.context = context
self.request = request

self.permission_cache = {}
self.permission_cache: Dict[str, bool] = {}

async def __call__(self, data, validate_all=False, ignore_errors=False, create=False):
errors = []
async def __call__(
self,
data: Dict[str, Any],
validate_all: bool = False,
ignore_errors: bool = False,
create: bool = False,
) -> IBaseObject:
errors: List[Dict[str, Any]] = []

# do behavior first in case they modify context values
for behavior_schema, behavior in await get_all_behaviors(self.context, load=False):
Expand Down Expand Up @@ -77,7 +91,15 @@ async def __call__(self, data, validate_all=False, ignore_errors=False, create=F

return self.context

async def set_schema(self, schema, obj, data, errors, validate_all=False, behavior=False):
async def set_schema(
self,
schema: Type[Interface],
obj: IBaseObject,
data: Dict[str, Any],
errors: List[Dict[str, Any]],
validate_all: bool = False,
behavior: bool = False,
):
write_permissions = merged_tagged_value_dict(schema, write_permission.key)
changed = False
for name, field in get_fields(schema).items():
Expand Down Expand Up @@ -145,23 +167,27 @@ async def set_schema(self, schema, obj, data, errors, validate_all=False, behavi
}
)

invariant_errors = [] # type: ignore
try:
schema.validateInvariants(obj, invariant_errors)
except Invalid:
# Just collect errors
pass
for error in invariant_errors:
if len(getattr(error, "args", [])) > 0 and isinstance(error.args[0], str):
message = error.args[0]
for error in await validate_invariants(schema, obj):
if isinstance(error, ValidationError):
errors.append(
{
"message": error.doc(),
"value": error.value,
"field": error.field_name,
"error": error.errors,
}
)
else:
message = error.__doc__
errors.append({"message": message, "error": error})
if len(getattr(error, "args", [])) > 0 and isinstance(error.args[0], str):
message = error.args[0]
else:
message = error.__doc__
errors.append({"message": message, "error": error})

if changed:
obj.register()

async def get_value(self, field, obj, value):
async def get_value(self, field: IField, obj: IBaseObject, value: Any) -> Any:
try:
if value is not None:
value = get_adapter(field, IJSONToValue, args=[value, obj])
Expand All @@ -172,7 +198,7 @@ async def get_value(self, field, obj, value):
except ComponentLookupError:
raise ValueDeserializationError(field, value, "Deserializer not found for field")

def check_permission(self, permission_name):
def check_permission(self, permission_name: str) -> bool:
if permission_name is None:
return True

Expand Down
28 changes: 27 additions & 1 deletion guillotina/json/utils.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
from guillotina.component import get_multi_adapter
from guillotina.db.orm.interfaces import IBaseObject
from guillotina.exceptions import RequestNotFound
from guillotina.interfaces import ISchemaSerializeToJson
from guillotina.utils import get_current_request
from typing import Any
from typing import Dict
from typing import List
from typing import Type
from zope.interface import Interface
from zope.interface import Invalid

import asyncio
import logging


logger = logging.getLogger("guillotina")


def convert_interfaces_to_schema(interfaces):
def convert_interfaces_to_schema(interfaces: List[Type[Interface]]) -> Dict[str, Any]:
properties = {}
try:
request = get_current_request()
Expand All @@ -22,3 +30,21 @@ def convert_interfaces_to_schema(interfaces):
serializer = get_multi_adapter((iface, request), ISchemaSerializeToJson)
properties[iface.__identifier__] = serializer.serialize()
return properties


async def validate_invariants(schema: Type[Interface], obj: IBaseObject) -> List[Invalid]:
"""
Validate invariants on a schema with async invariant support.
"""
errors = []
for call in schema.queryTaggedValue("invariants", []):
try:
if asyncio.iscoroutinefunction(call):
await call(obj)
else:
call(obj)
except Invalid as e:
errors.append(e)
for base in schema.__bases__:
errors.extend(await validate_invariants(base, obj))
return errors
11 changes: 11 additions & 0 deletions guillotina/schema/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ class StopValidation(Exception):
class ValidationError(zope.interface.Invalid):
"""Raised if the Validation process fails."""

def __init__(self, value=None, type=None, field_name="", errors=None, constraint=None):
"""
Follow guillotina 6 api but not fully support.
"""
super().__init__(value)
self.value = value
self.type = type
self.field_name = field_name
self.errors = errors
self.constraint = constraint

def doc(self):
return self.__class__.__doc__

Expand Down
41 changes: 39 additions & 2 deletions guillotina/tests/test_serialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@
from guillotina.json import deserialize_value
from guillotina.json.deserialize_value import schema_compatible
from guillotina.json.serialize_value import json_compatible
from guillotina.json.utils import validate_invariants
from guillotina.schema.exceptions import WrongType
from guillotina.tests.utils import create_content
from guillotina.tests.utils import login
from zope.interface import Interface
from zope.interface import Invalid
from zope.interface.interface import invariant

import pytest
import uuid
import zope.interface


async def test_serialize_resource(dummy_request, mock_txn):
Expand Down Expand Up @@ -925,7 +926,7 @@ class ITestValidation(Interface):

validated_text = schema.Text(required=False)

@zope.interface.invariant # type: ignore
@invariant
def text_should_not_be_foobar(ob):
if getattr(ob, "foo", None) == "foo" and getattr(ob, "bar", None) == "bar":
raise Invalid(ob)
Expand Down Expand Up @@ -1068,3 +1069,39 @@ async def test_bucket_dict_max_ops(dummy_guillotina, mock_txn):
content,
{"op": "update", "value": [{"key": "foo", "value": "bar"}, {"key": "foo", "value": "bar"}]},
)


class ITestSchemaWithInvariant(Interface):
foobar = schema.Text()

@invariant
async def validate_obj(obj):
if obj.foobar == "foobar":
raise Invalid("Value is foobar")
else:
raise schema.ValidationError(value=obj.foobar, field_name="foobar", errors=["Wrong value"])


async def test_async_invariant():
content = create_content()
content.foobar = "foobar"
assert len(await validate_invariants(ITestSchemaWithInvariant, content)) == 1

content = create_content()
content.foobar = "not foobar"
assert len(await validate_invariants(ITestSchemaWithInvariant, content)) == 1


async def test_async_invariant_deserializer(dummy_guillotina, mock_txn, dummy_request):
login()
content = create_content()
deserializer = get_multi_adapter((content, dummy_request), IResourceDeserializeFromJson)
errors = []
await deserializer.set_schema(ITestSchemaWithInvariant, content, {"foobar": "foobar"}, errors)
assert len(errors) == 1
assert errors[0]["message"] == "Value is foobar"

errors = []
await deserializer.set_schema(ITestSchemaWithInvariant, content, {"foobar": "not foobar"}, errors)
assert len(errors) == 1
assert errors[0]["error"][0] == "Wrong value"

0 comments on commit 1212c6f

Please sign in to comment.