Skip to content

Commit

Permalink
WIP: Serializers/Deserializers for schemas
Browse files Browse the repository at this point in the history
  • Loading branch information
ericof committed Nov 1, 2024
1 parent e2d4e70 commit 5a30e9b
Show file tree
Hide file tree
Showing 13 changed files with 280 additions and 183 deletions.
1 change: 1 addition & 0 deletions news/1832.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support the registration of serializers and deserializers per schema [@ericof]
2 changes: 2 additions & 0 deletions src/plone/restapi/deserializer/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
<adapter factory=".site.DeserializeSiteRootFromJson" />
<adapter factory=".dxcontent.DeserializeFromJson" />

<adapter factory=".dxschema.DeserializeFromJson" />

<adapter factory=".dxfields.DefaultFieldDeserializer" />
<adapter factory=".dxfields.DatetimeFieldDeserializer" />
<adapter factory=".dxfields.ChoiceFieldDeserializer" />
Expand Down
132 changes: 7 additions & 125 deletions src/plone/restapi/deserializer/dxcontent.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,20 @@
from .mixins import OrderingMixin
from AccessControl import getSecurityManager
from plone.autoform.interfaces import WRITE_PERMISSIONS_KEY
from plone.dexterity.interfaces import IDexterityContent
from plone.dexterity.utils import iterSchemata
from plone.restapi import _
from plone.restapi.deserializer import json_body
from plone.restapi.interfaces import IDeserializeFromJson
from plone.restapi.interfaces import IFieldDeserializer
from plone.supermodel.utils import mergedTaggedValueDict
from z3c.form.interfaces import IDataManager
from plone.restapi.deserializer.utils import deserialize_schemas
from z3c.form.interfaces import IManagerValidator
from zExceptions import BadRequest
from zope.component import adapter
from zope.component import queryMultiAdapter
from zope.component import queryUtility
from zope.event import notify
from zope.i18n import translate
from zope.interface import implementer
from zope.interface import Interface
from zope.lifecycleevent import Attributes
from zope.lifecycleevent import ObjectModifiedEvent
from zope.schema import getFields
from zope.schema.interfaces import ValidationError
from zope.security.interfaces import IPermission


@implementer(IDeserializeFromJson)
Expand All @@ -34,7 +26,6 @@ def __init__(self, context, request):

self.sm = getSecurityManager()
self.permission_cache = {}
self.modified = {}

def __call__(
self, validate_all=False, data=None, create=False, mask_validation_errors=True
Expand All @@ -43,7 +34,10 @@ def __call__(
if data is None:
data = json_body(self.request)

schema_data, errors = self.get_schema_data(data, validate_all, create)
# Deserialize JSON
schema_data, errors, modified = deserialize_schemas(
self.context, self.request, data, validate_all, create
)

# Validate schemata
for schema, field_data in schema_data.items():
Expand Down Expand Up @@ -72,122 +66,10 @@ def __call__(
# OrderingMixin
self.handle_ordering(data)

if self.modified and not create:
if modified and not create:
descriptions = []
for interface, names in self.modified.items():
for interface, names in modified.items():
descriptions.append(Attributes(interface, *names))
notify(ObjectModifiedEvent(self.context, *descriptions))

return self.context

def get_schema_data(self, data, validate_all, create=False):
schema_data = {}
errors = []

for schema in iterSchemata(self.context):
write_permissions = mergedTaggedValueDict(schema, WRITE_PERMISSIONS_KEY)

for name, field in getFields(schema).items():
__traceback_info__ = f"field={field}"

field_data = schema_data.setdefault(schema, {})

if field.readonly:
continue

if name in data:
dm = queryMultiAdapter((self.context, field), IDataManager)
if not dm.canWrite():
continue

if not self.check_permission(write_permissions.get(name)):
continue

# set the field to missing_value if we receive null
if data[name] is None:
if not field.required:
if dm.get():
self.mark_field_as_changed(schema, name)
dm.set(field.missing_value)
else:
errors.append(
{
"field": field.__name__,
"message": _(
"${field_name} is a required field."
" Setting it to null is not allowed.",
mapping={"field_name": field.__name__},
),
}
)
continue

# Deserialize to field value
deserializer = queryMultiAdapter(
(field, self.context, self.request), IFieldDeserializer
)
if deserializer is None:
continue

try:
value = deserializer(data[name])
except ValueError as e:
errors.append({"message": str(e), "field": name, "error": e})
except ValidationError as e:
errors.append({"message": e.doc(), "field": name, "error": e})
else:
field_data[name] = value
current_value = dm.get()
if value != current_value:
should_change = True
elif create and dm.field.defaultFactory:
# During content creation we should set the value even if
# it is the same from the dm if the current_value was
# returned from a default_factory method
should_change = (
dm.field.defaultFactory(self.context) == current_value
)
else:
should_change = False

if should_change:
dm.set(value)
self.mark_field_as_changed(schema, name)

elif validate_all:
# Never validate the changeNote of p.a.versioningbehavior
# The Versionable adapter always returns an empty string
# which is the wrong type. Should be unicode and should be
# fixed in p.a.versioningbehavior
if name == "changeNote":
continue
dm = queryMultiAdapter((self.context, field), IDataManager)
bound = field.bind(self.context)
try:
bound.validate(dm.get())
except ValidationError as e:
errors.append({"message": e.doc(), "field": name, "error": e})

return schema_data, errors

def mark_field_as_changed(self, schema, fieldname):
"""Collect the names of the modified fields. Use prefixed name because
z3c.form does so.
"""

prefixed_name = schema.__name__ + "." + fieldname
self.modified.setdefault(schema, []).append(prefixed_name)

def check_permission(self, permission_name):
if permission_name is None:
return True

if permission_name not in self.permission_cache:
permission = queryUtility(IPermission, name=permission_name)
if permission is None:
self.permission_cache[permission_name] = True
else:
self.permission_cache[permission_name] = bool(
self.sm.checkPermission(permission.title, self.context)
)
return self.permission_cache[permission_name]
123 changes: 123 additions & 0 deletions src/plone/restapi/deserializer/dxschema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
from plone.autoform.interfaces import WRITE_PERMISSIONS_KEY
from plone.dexterity.interfaces import IDexterityContent
from plone.restapi import _
from plone.restapi.interfaces import IFieldDeserializer
from plone.restapi.interfaces import ISchemaDeserializer
from plone.restapi.permissions import check_permission
from plone.supermodel.utils import mergedTaggedValueDict
from z3c.form.interfaces import IDataManager
from zope.component import adapter
from zope.component import queryMultiAdapter
from zope.interface import implementer
from zope.interface import Interface
from zope.schema import getFields
from zope.schema.interfaces import ValidationError
from zope.interface.interfaces import IInterface


@implementer(ISchemaDeserializer)
@adapter(IInterface, IDexterityContent, Interface)
class DeserializeFromJson:
def __init__(self, schema, context, request):
self.schema = schema
self.context = context
self.request = request
self.permission_cache = {}
self.modified = {}

def mark_field_as_changed(self, schema, fieldname):
"""Collect the names of the modified fields. Use prefixed name because
z3c.form does so.
"""

prefixed_name = f"{schema.__name__}.{fieldname}"
self.modified.setdefault(schema, []).append(prefixed_name)

def __call__(self, data, validate_all, create=False) -> tuple[dict, list, dict]:
schema = self.schema
schema_data = {}
errors = []
write_permissions = mergedTaggedValueDict(schema, WRITE_PERMISSIONS_KEY)
for name, field in getFields(schema).items():
__traceback_info__ = f"field={field}"

field_data = schema_data.setdefault(schema, {})

if field.readonly:
continue

if name in data:
dm = queryMultiAdapter((self.context, field), IDataManager)
if not dm.canWrite():
continue

if not check_permission(
write_permissions.get(name), self.context, self.permission_cache
):
continue

# set the field to missing_value if we receive null
if data[name] is None:
if not field.required:
if dm.get():
self.mark_field_as_changed(schema, name)
dm.set(field.missing_value)
else:
errors.append(
{
"field": field.__name__,
"message": _(
"${field_name} is a required field."
" Setting it to null is not allowed.",
mapping={"field_name": field.__name__},
),
}
)
continue

# Deserialize to field value
deserializer = queryMultiAdapter(
(field, self.context, self.request), IFieldDeserializer
)
if deserializer is None:
continue

try:
value = deserializer(data[name])
except ValueError as e:
errors.append({"message": str(e), "field": name, "error": e})
except ValidationError as e:
errors.append({"message": e.doc(), "field": name, "error": e})
else:
field_data[name] = value
current_value = dm.get()
if value != current_value:
should_change = True
elif create and dm.field.defaultFactory:
# During content creation we should set the value even if
# it is the same from the dm if the current_value was
# returned from a default_factory method
should_change = (
dm.field.defaultFactory(self.context) == current_value
)
else:
should_change = False

if should_change:
dm.set(value)
self.mark_field_as_changed(schema, name)
elif validate_all:
# Never validate the changeNote of p.a.versioningbehavior
# The Versionable adapter always returns an empty string
# which is the wrong type. Should be unicode and should be
# fixed in p.a.versioningbehavior
if name == "changeNote":
continue
dm = queryMultiAdapter((self.context, field), IDataManager)
bound = field.bind(self.context)
try:
bound.validate(dm.get())
except ValidationError as e:
errors.append({"message": e.doc(), "field": name, "error": e})

return schema_data, errors, self.modified
26 changes: 26 additions & 0 deletions src/plone/restapi/deserializer/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
from Acquisition import aq_parent
from plone.dexterity.content import DexterityContent
from plone.restapi.interfaces import ISchemaDeserializer
from plone.uuid.interfaces import IUUID
from plone.uuid.interfaces import IUUIDAware
from zope.component import getMultiAdapter
from plone.dexterity.utils import iterSchemata
from zope.component import queryMultiAdapter
from ZPublisher.HTTPRequest import HTTPRequest
import re

PATH_RE = re.compile(r"^(.*?)((?=/@@|#).*)?$")
Expand Down Expand Up @@ -50,3 +55,24 @@ def path2uid(context, link):
if suffix:
href += suffix
return href


def deserialize_schemas(
context: DexterityContent,
request: HTTPRequest,
data: dict,
validate_all: bool,
create: bool = False,
) -> tuple[dict, list, dict]:
result = {}
errors = []
modified = {}
for schema in iterSchemata(context):
serializer = queryMultiAdapter((schema, context, request), ISchemaDeserializer)
schema_data, schema_errors, schema_modified = serializer(
data, validate_all, create
)
result.update(schema_data)
errors.extend(schema_errors)
modified.update(schema_modified)
return result, errors, modified
24 changes: 23 additions & 1 deletion src/plone/restapi/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ def __init__(value, context):
"""Adapts value and a context"""


class ISchemaSerializer(Interface):
"""The schema serializer multi adapter serializes a schema into
JSON compatible python data.
"""

def __init__(schema, context, request):
"""Adapts schema, context and request."""

def __call__():
"""Returns JSON compatible python data."""


class IFieldSerializer(Interface):
"""The field serializer multi adapter serializes the field value into
JSON compatible python data.
Expand Down Expand Up @@ -73,11 +85,21 @@ class IDeserializeFromJson(Interface):
"""An adapter to deserialize a JSON object into an object in Plone."""


class ISchemaDeserializer(Interface):
"""An adapter to deserialize a JSON value from a schema."""

def __init__(schema, context, request):
"""Adapts schema, context and request."""

def __call__(data, validate_all, create):
"""Convert the provided JSON value to a field value."""


class IFieldDeserializer(Interface):
"""An adapter to deserialize a JSON value into a field value."""

def __init__(field, context, request):
"""Adapts a field, it's context and the request."""
"""Adapts a field, its context and the request."""

def __call__(value):
"""Convert the provided JSON value to a field value."""
Expand Down
Loading

0 comments on commit 5a30e9b

Please sign in to comment.