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

WIP: Serializers/Deserializers for schemas #1833

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
133 changes: 7 additions & 126 deletions src/plone/restapi/deserializer/dxcontent.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,19 @@
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 +25,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 +33,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 +65,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
Loading