diff --git a/news/1832.feature b/news/1832.feature
new file mode 100644
index 0000000000..70b5976371
--- /dev/null
+++ b/news/1832.feature
@@ -0,0 +1 @@
+Support the registration of serializers and deserializers per schema [@ericof]
\ No newline at end of file
diff --git a/src/plone/restapi/deserializer/configure.zcml b/src/plone/restapi/deserializer/configure.zcml
index f7b4d5c5ac..5d7d516d6d 100644
--- a/src/plone/restapi/deserializer/configure.zcml
+++ b/src/plone/restapi/deserializer/configure.zcml
@@ -7,6 +7,8 @@
+
+
diff --git a/src/plone/restapi/deserializer/dxcontent.py b/src/plone/restapi/deserializer/dxcontent.py
index dd6bac1851..ed1601576f 100644
--- a/src/plone/restapi/deserializer/dxcontent.py
+++ b/src/plone/restapi/deserializer/dxcontent.py
@@ -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)
@@ -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
@@ -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():
@@ -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]
diff --git a/src/plone/restapi/deserializer/dxschema.py b/src/plone/restapi/deserializer/dxschema.py
new file mode 100644
index 0000000000..4550aad803
--- /dev/null
+++ b/src/plone/restapi/deserializer/dxschema.py
@@ -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
diff --git a/src/plone/restapi/deserializer/utils.py b/src/plone/restapi/deserializer/utils.py
index 67d67d5556..5d039e7dee 100644
--- a/src/plone/restapi/deserializer/utils.py
+++ b/src/plone/restapi/deserializer/utils.py
@@ -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"^(.*?)((?=/@@|#).*)?$")
@@ -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
diff --git a/src/plone/restapi/interfaces.py b/src/plone/restapi/interfaces.py
index 5c2aa337e6..d01909689d 100644
--- a/src/plone/restapi/interfaces.py
+++ b/src/plone/restapi/interfaces.py
@@ -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.
@@ -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."""
diff --git a/src/plone/restapi/permissions.py b/src/plone/restapi/permissions.py
index af1e36b361..968287248e 100644
--- a/src/plone/restapi/permissions.py
+++ b/src/plone/restapi/permissions.py
@@ -1,6 +1,31 @@
+from plone.dexterity.content import DexterityContent
+from zope.security.interfaces import IPermission
+from AccessControl import getSecurityManager
+from zope.component import queryUtility
+
# # Required to use the REST API at all, in addition to service specific
# permissions. Granted to Anonymous (i.e. everyone) by default via rolemap.xml
UseRESTAPI = "plone.restapi: Use REST API"
PloneManageUsers = "Plone Site Setup: Users and Groups"
+
+
+def check_permission(
+ permission_name: str, context: DexterityContent, permission_cache: dict = None
+):
+ if permission_name is None:
+ return True
+ elif permission_cache is None:
+ permission_cache = {}
+
+ if permission_name not in permission_cache:
+ permission = queryUtility(IPermission, name=permission_name)
+ if permission is None:
+ permission_cache[permission_name] = True
+ else:
+ sm = getSecurityManager()
+ permission_cache[permission_name] = bool(
+ sm.checkPermission(permission.title, context)
+ )
+ return permission_cache[permission_name]
diff --git a/src/plone/restapi/profiles/testing/types.xml b/src/plone/restapi/profiles/testing/types.xml
index 0202341fac..05b916633f 100644
--- a/src/plone/restapi/profiles/testing/types.xml
+++ b/src/plone/restapi/profiles/testing/types.xml
@@ -6,5 +6,4 @@
-
diff --git a/src/plone/restapi/serializer/configure.zcml b/src/plone/restapi/serializer/configure.zcml
index 0e84f64f42..d00981e015 100644
--- a/src/plone/restapi/serializer/configure.zcml
+++ b/src/plone/restapi/serializer/configure.zcml
@@ -5,6 +5,8 @@
>
+
+
diff --git a/src/plone/restapi/serializer/dxcontent.py b/src/plone/restapi/serializer/dxcontent.py
index 1c546d091d..0e598ed5f9 100644
--- a/src/plone/restapi/serializer/dxcontent.py
+++ b/src/plone/restapi/serializer/dxcontent.py
@@ -7,7 +7,6 @@
from plone.dexterity.utils import iterSchemata
from plone.restapi.batching import HypermediaBatch
from plone.restapi.deserializer import boolean_value
-from plone.restapi.interfaces import IFieldSerializer
from plone.restapi.interfaces import IObjectPrimaryFieldTarget
from plone.restapi.interfaces import IPrimaryFieldTarget
from plone.restapi.interfaces import ISerializeToJson
@@ -17,6 +16,7 @@
from plone.restapi.serializer.nextprev import NextPrevious
from plone.restapi.services.locking import lock_info
from plone.restapi.serializer.utils import get_portal_type_title
+from plone.restapi.serializer.utils import serialize_schemas
from plone.rfc822.interfaces import IPrimaryFieldInfo
from plone.supermodel.utils import mergedTaggedValueDict
from Products.CMFCore.utils import getToolByName
@@ -118,21 +118,8 @@ def __call__(self, version=None, include_items=True):
# Insert expandable elements
result.update(expandable_elements(self.context, self.request))
-
# Insert field values
- for schema in iterSchemata(self.context):
- read_permissions = mergedTaggedValueDict(schema, READ_PERMISSIONS_KEY)
-
- for name, field in getFields(schema).items():
- if not self.check_permission(read_permissions.get(name), obj):
- continue
-
- # serialize the field
- serializer = queryMultiAdapter(
- (field, obj, self.request), IFieldSerializer
- )
- value = serializer()
- result[json_compatible(name)] = value
+ result.update(serialize_schemas(obj, self.request))
target_url = getMultiAdapter(
(self.context, self.request), IObjectPrimaryFieldTarget
diff --git a/src/plone/restapi/serializer/dxschema.py b/src/plone/restapi/serializer/dxschema.py
new file mode 100644
index 0000000000..277cd7f9ce
--- /dev/null
+++ b/src/plone/restapi/serializer/dxschema.py
@@ -0,0 +1,54 @@
+from plone.autoform.interfaces import READ_PERMISSIONS_KEY
+from plone.dexterity.interfaces import IDexterityContent
+from plone.restapi.interfaces import IFieldSerializer
+from plone.restapi.interfaces import ISchemaSerializer
+from plone.restapi.serializer.converters import json_compatible
+from plone.restapi.permissions import check_permission
+from plone.supermodel.utils import mergedTaggedValueDict
+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 plone.dexterity.content import DexterityContent
+from zope.interface.interfaces import IInterface
+from plone.dexterity.interfaces import IContentType
+from ZPublisher.HTTPRequest import HTTPRequest
+
+
+class BaseSerializer:
+ def __init__(self, schema, context: DexterityContent, request: HTTPRequest):
+ self.schema = schema
+ self.context = context
+ self.request = request
+ self.permission_cache = {}
+
+ def __call__(self):
+ result = {}
+ schema = self.schema
+ read_permissions = mergedTaggedValueDict(schema, READ_PERMISSIONS_KEY)
+ for name, field in getFields(schema).items():
+ if not check_permission(
+ read_permissions.get(name), self.context, self.permission_cache
+ ):
+ continue
+
+ # serialize the field
+ serializer = queryMultiAdapter(
+ (field, self.context, self.request), IFieldSerializer
+ )
+ value = serializer()
+ result[json_compatible(name)] = value
+ return result
+
+
+@implementer(ISchemaSerializer)
+@adapter(IInterface, IDexterityContent, Interface)
+class SerializeSchemaToJson(BaseSerializer):
+ """Serialize ISchema to JSON."""
+
+
+@implementer(ISchemaSerializer)
+@adapter(IContentType, IDexterityContent, Interface)
+class DXSchemaSerializeToJson(BaseSerializer):
+ """Serialize IDexteritySchema to JSON."""
diff --git a/src/plone/restapi/serializer/site.py b/src/plone/restapi/serializer/site.py
index 65081fd2ea..b7ae6f5b3b 100644
--- a/src/plone/restapi/serializer/site.py
+++ b/src/plone/restapi/serializer/site.py
@@ -1,30 +1,20 @@
-from AccessControl import getSecurityManager
from importlib import import_module
-from plone.autoform.interfaces import READ_PERMISSIONS_KEY
-from plone.dexterity.utils import iterSchemata
from plone.restapi.batching import HypermediaBatch
from plone.restapi.bbb import IPloneSiteRoot
from plone.restapi.blocks import iter_block_transform_handlers
from plone.restapi.blocks import visit_blocks
from plone.restapi.interfaces import IBlockFieldSerializationTransformer
-from plone.restapi.interfaces import IFieldSerializer
from plone.restapi.interfaces import ISerializeToJson
from plone.restapi.interfaces import ISerializeToJsonSummary
-from plone.restapi.serializer.converters import json_compatible
from plone.restapi.serializer.dxcontent import get_allow_discussion_value
from plone.restapi.serializer.expansion import expandable_elements
-from plone.restapi.serializer.utils import get_portal_type_title
+from plone.restapi.serializer.utils import get_portal_type_title, serialize_schemas
from plone.restapi.services.locking import lock_info
-from plone.supermodel.utils import mergedTaggedValueDict
from Products.CMFCore.utils import getToolByName
from zope.component import adapter
from zope.component import getMultiAdapter
-from zope.component import queryMultiAdapter
-from zope.component import queryUtility
from zope.interface import implementer
from zope.interface import Interface
-from zope.schema import getFields
-from zope.security.interfaces import IPermission
import json
@@ -40,7 +30,6 @@ class SerializeSiteRootToJson:
def __init__(self, context, request):
self.context = context
self.request = request
- self.permission_cache = {}
def _build_query(self):
path = "/".join(self.context.getPhysicalPath())
@@ -83,21 +72,7 @@ def __call__(self, version=None):
)
# Insert Plone Site DX root field values
- for schema in iterSchemata(self.context):
- read_permissions = mergedTaggedValueDict(schema, READ_PERMISSIONS_KEY)
-
- for name, field in getFields(schema).items():
- if not self.check_permission(
- read_permissions.get(name), self.context
- ):
- continue
-
- # serialize the field
- serializer = queryMultiAdapter(
- (field, self.context, self.request), IFieldSerializer
- )
- value = serializer()
- result[json_compatible(name)] = value
+ result.update(serialize_schemas(self.context, self.request))
# Insert locking information
result.update({"lock": lock_info(self.context)})
@@ -128,21 +103,6 @@ def __call__(self, version=None):
return result
- def check_permission(self, permission_name, obj):
- 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:
- sm = getSecurityManager()
- self.permission_cache[permission_name] = bool(
- sm.checkPermission(permission.title, obj)
- )
- return self.permission_cache[permission_name]
-
def serialize_blocks(self):
# This is only for below 6
blocks = json.loads(getattr(self.context, "blocks", "{}"))
diff --git a/src/plone/restapi/serializer/utils.py b/src/plone/restapi/serializer/utils.py
index 89ce0b005a..cadeeb8c6c 100644
--- a/src/plone/restapi/serializer/utils.py
+++ b/src/plone/restapi/serializer/utils.py
@@ -1,9 +1,13 @@
from plone.app.uuid.utils import uuidToCatalogBrain
+from plone.dexterity.content import DexterityContent
from plone.dexterity.schema import lookup_fti
from plone.restapi.interfaces import IObjectPrimaryFieldTarget
from zope.component import queryMultiAdapter
from zope.globalrequest import getRequest
from zope.i18n import translate
+from plone.dexterity.utils import iterSchemata
+from plone.restapi.interfaces import ISchemaSerializer
+from ZPublisher.HTTPRequest import HTTPRequest
import re
@@ -53,3 +57,11 @@ def get_portal_type_title(portal_type):
if request:
return translate(getattr(fti, "Title", lambda: portal_type)(), context=request)
return getattr(fti, "Title", lambda: portal_type)()
+
+
+def serialize_schemas(context: DexterityContent, request: HTTPRequest) -> dict:
+ result = {}
+ for schema in iterSchemata(context):
+ serializer = queryMultiAdapter((schema, context, request), ISchemaSerializer)
+ result.update(serializer())
+ return result