Skip to content

Commit 763ce18

Browse files
davisaglistevepiercyjensens
authored
Add @inherit service to get inherited behavior values (#1887)
* Add `@inherit` service to get inherited behavior values * changelog * Check view permission to prevent using this to read data that should be private * Nest behaviors under inherit * edit intro to docs * Apply suggestions from code review Co-authored-by: Steve Piercy <[email protected]> Co-authored-by: Jens W. Klein <[email protected]> --------- Co-authored-by: Steve Piercy <[email protected]> Co-authored-by: Jens W. Klein <[email protected]>
1 parent 4b63e05 commit 763ce18

19 files changed

+371
-86
lines changed

Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ check: $(BIN_FOLDER)/tox ## Check and fix code base according to Plone standards
116116

117117
.PHONY: test
118118
test: $(BIN_FOLDER)/zope-testrunner ## Run tests
119-
$(BIN_FOLDER)/zope-testrunner --all --test-path=src -s plone.restapi
119+
zope_i18n_compile_mo_files=True $(BIN_FOLDER)/zope-testrunner --all --test-path=src -s plone.restapi
120120

121121
.PHONY: i18n
122122
i18n: $(BIN_FOLDER)/update_restapi_locales ## Update locales

docs/source/endpoints/index.md

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ email-notification
3131
email-send
3232
groups
3333
history
34+
inherit
3435
linkintegrity
3536
locking
3637
login

docs/source/endpoints/inherit.md

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
myst:
3+
html_meta:
4+
"description": "The inherit endpoint shows values inherited from content items higher in the hierarchy."
5+
"property=og:description": "The inherit endpoint shows values inherited from content items higher in the hierarchy."
6+
"property=og:title": "Inherit behaviors"
7+
"keywords": "Plone, plone.restapi, REST, API, inherit, acquisition, behaviors"
8+
---
9+
10+
(inherit-behaviors-label)=
11+
12+
# Inherit behaviors
13+
14+
Plone content is arranged in a hierarchy: a content item has a parent, which has its own parent, all the way up to the Plone site root.
15+
Together, all these parents are _ancestors_.
16+
17+
The `@inherit` service makes it possible to access data from a behavior defined on one of these ancestors.
18+
19+
```{tip}
20+
Inheriting behaviors is similar to the concept of {term}`acquisition` in Zope, but it doesn't happen automatically, so it's safer.
21+
```
22+
23+
To use the service, send a `GET` request to the `@inherit` endpoint in the context of the content item that is the starting point for inheriting.
24+
Specify the `expand.inherit.behaviors` parameter as a comma-separated list of behaviors.
25+
26+
```{eval-rst}
27+
.. http:example:: curl httpie python-requests
28+
:request: ../../../src/plone/restapi/tests/http-examples/inherit_get.req
29+
```
30+
31+
For each behavior, the service will find the closest ancestor which provides that behavior.
32+
The result includes `from` (the `@id` and `title` of the item from which values were inherited) and `data` (values for any fields that are part of the behavior).
33+
34+
```{literalinclude} ../../../src/plone/restapi/tests/http-examples/inherit_get.resp
35+
:language: http
36+
```
37+
38+
Ancestor items for which the current user lacks the `View` permission will be skipped.
39+
40+
(inherit-behaviors-expansion-label)=
41+
42+
## Expansion
43+
44+
This endpoint can be used with the {doc}`../usage/expansion` mechanism which allows getting more information about a content item in one query, avoiding unnecessary requests.
45+
46+
You can make a `GET` request for a content item, and include parameters to request `inherit` expansion for specific behaviors:
47+
48+
```{eval-rst}
49+
.. http:example:: curl httpie python-requests
50+
:request: ../../../src/plone/restapi/tests/http-examples/inherit_expansion.req
51+
```
52+
53+
The response will include data from the `@inherit` endpoint within the `@components` property:
54+
55+
```{literalinclude} ../../../src/plone/restapi/tests/http-examples/inherit_expansion.resp
56+
:language: http
57+
```

docs/source/usage/expansion.md

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ The following is a list of components that support expansion.
2323
- {doc}`aliases <../endpoints/aliases>`
2424
- {doc}`breadcrumbs <../endpoints/breadcrumbs>`
2525
- {doc}`contextnavigation <../endpoints/contextnavigation>`
26+
- {doc}`inherit <../endpoints/inherit>`
2627
- {doc}`navigation <../endpoints/navigation>`
2728
- {doc}`navroot <../endpoints/navroot>`
2829
- {doc}`translations <../endpoints/translations>`

news/1887.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a new endpoint, `@inherit`, for getting values from behaviors inherited from ancestors in the object hierarchy. @davisagli

src/plone/restapi/interfaces.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ def __init__(value, context):
3232
"""Adapts value and a context"""
3333

3434

35+
class ISchemaSerializer(Interface):
36+
"""The schema serializer serializes all field values from a schema
37+
into JSON-compatible Python data."""
38+
39+
def __init__(schema, context, request):
40+
"""Adapts schema, context, and request."""
41+
42+
def __call__():
43+
"""Returns JSON-compatible Python data."""
44+
45+
3546
class IFieldSerializer(Interface):
3647
"""The field serializer multi adapter serializes the field value into
3748
JSON compatible python data.
@@ -41,7 +52,7 @@ def __init__(field, context, request):
4152
"""Adapts field, context and request."""
4253

4354
def __call__():
44-
"""Returns JSON compatible python data."""
55+
"""Returns JSON-compatible Python data."""
4556

4657

4758
class IPrimaryFieldTarget(Interface):

src/plone/restapi/serializer/configure.zcml

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
<adapter factory=".summary.DefaultJSONSummarySerializer" />
1919
<adapter factory=".summary.SiteRootJSONSummarySerializer" />
2020

21+
<adapter factory=".schema.SerializeSchemaToJson" />
22+
2123
<adapter factory=".dxfields.DefaultFieldSerializer" />
2224
<adapter factory=".dxfields.ChoiceFieldSerializer" />
2325
<adapter factory=".dxfields.CollectionFieldSerializer" />

src/plone/restapi/serializer/dxcontent.py

+10-47
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from AccessControl import getSecurityManager
21
from Acquisition import aq_inner
32
from Acquisition import aq_parent
43
from plone.app.contenttypes.interfaces import ILink
@@ -12,11 +11,13 @@
1211
from plone.restapi.interfaces import IFieldSerializer
1312
from plone.restapi.interfaces import IObjectPrimaryFieldTarget
1413
from plone.restapi.interfaces import IPrimaryFieldTarget
14+
from plone.restapi.interfaces import ISchemaSerializer
1515
from plone.restapi.interfaces import ISerializeToJson
1616
from plone.restapi.interfaces import ISerializeToJsonSummary
1717
from plone.restapi.serializer.converters import json_compatible
1818
from plone.restapi.serializer.expansion import expandable_elements
1919
from plone.restapi.serializer.nextprev import NextPrevious
20+
from plone.restapi.serializer.schema import check_permission as _check_permission
2021
from plone.restapi.serializer.utils import get_portal_type_title
2122
from plone.restapi.services.locking import lock_info
2223
from plone.rfc822.interfaces import IPrimaryFieldInfo
@@ -27,11 +28,9 @@
2728
from zope.component import ComponentLookupError
2829
from zope.component import getMultiAdapter
2930
from zope.component import queryMultiAdapter
30-
from zope.component import queryUtility
3131
from zope.interface import implementer
3232
from zope.interface import Interface
3333
from zope.schema import getFields
34-
from zope.security.interfaces import IPermission
3534

3635

3736
try:
@@ -75,8 +74,6 @@ def __init__(self, context, request):
7574
self.context = context
7675
self.request = request
7776

78-
self.permission_cache = {}
79-
8077
def getVersion(self, version):
8178
if version == "current":
8279
return self.context
@@ -131,18 +128,10 @@ def __call__(self, version=None, include_items=True):
131128

132129
# Insert field values
133130
for schema in iterSchemata(self.context):
134-
read_permissions = mergedTaggedValueDict(schema, READ_PERMISSIONS_KEY)
135-
136-
for name, field in getFields(schema).items():
137-
if not self.check_permission(read_permissions.get(name), obj):
138-
continue
139-
140-
# serialize the field
141-
serializer = queryMultiAdapter(
142-
(field, obj, self.request), IFieldSerializer
143-
)
144-
value = serializer()
145-
result[json_compatible(name)] = value
131+
schema_serializer = getMultiAdapter(
132+
(schema, obj, self.request), ISchemaSerializer
133+
)
134+
result.update(schema_serializer())
146135

147136
target_url = getMultiAdapter(
148137
(self.context, self.request), IObjectPrimaryFieldTarget
@@ -160,19 +149,8 @@ def _get_workflow_state(self, obj):
160149
return review_state
161150

162151
def check_permission(self, permission_name, obj):
163-
if permission_name is None:
164-
return True
165-
166-
if permission_name not in self.permission_cache:
167-
permission = queryUtility(IPermission, name=permission_name)
168-
if permission is None:
169-
self.permission_cache[permission_name] = True
170-
else:
171-
sm = getSecurityManager()
172-
self.permission_cache[permission_name] = bool(
173-
sm.checkPermission(permission.title, obj)
174-
)
175-
return self.permission_cache[permission_name]
152+
# Here for backwards-compatibility
153+
return _check_permission(permission_name, obj)
176154

177155

178156
@implementer(ISerializeToJson)
@@ -225,8 +203,6 @@ def __init__(self, context, request):
225203
self.context = context
226204
self.request = request
227205

228-
self.permission_cache = {}
229-
230206
def __call__(self):
231207
primary_field_name = self.get_primary_field_name()
232208
if not primary_field_name:
@@ -266,19 +242,8 @@ def get_primary_field_name(self):
266242
return fieldname
267243

268244
def check_permission(self, permission_name, obj):
269-
if permission_name is None:
270-
return True
271-
272-
if permission_name not in self.permission_cache:
273-
permission = queryUtility(IPermission, name=permission_name)
274-
if permission is None:
275-
self.permission_cache[permission_name] = True
276-
else:
277-
sm = getSecurityManager()
278-
self.permission_cache[permission_name] = bool(
279-
sm.checkPermission(permission.title, obj)
280-
)
281-
return self.permission_cache[permission_name]
245+
# for backwards-compatibility
246+
return _check_permission(permission_name, obj)
282247

283248

284249
@adapter(ILink, Interface)
@@ -288,8 +253,6 @@ def __init__(self, context, request):
288253
self.context = context
289254
self.request = request
290255

291-
self.permission_cache = {}
292-
293256
def __call__(self):
294257
"""
295258
If user can edit Link object, do not return remoteUrl
+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from AccessControl import getSecurityManager
2+
from plone.autoform.interfaces import READ_PERMISSIONS_KEY
3+
from plone.restapi.interfaces import IFieldSerializer
4+
from plone.restapi.interfaces import ISchemaSerializer
5+
from plone.restapi.serializer.converters import json_compatible
6+
from plone.supermodel.utils import mergedTaggedValueDict
7+
from zope.component import adapter
8+
from zope.component import getMultiAdapter
9+
from zope.component import queryUtility
10+
from zope.interface import implementer
11+
from zope.interface import Interface
12+
from zope.interface.interfaces import IInterface
13+
from zope.schema import getFields
14+
from zope.security.interfaces import IPermission
15+
16+
17+
@adapter(IInterface, Interface, Interface)
18+
@implementer(ISchemaSerializer)
19+
class SerializeSchemaToJson:
20+
"""Serialize fields from a single schema, honoring read permissions."""
21+
22+
def __init__(self, schema, context, request):
23+
self.context = context
24+
self.request = request
25+
self.schema = schema
26+
27+
def __call__(self):
28+
result = {}
29+
30+
read_permissions = mergedTaggedValueDict(self.schema, READ_PERMISSIONS_KEY)
31+
for name, field in getFields(self.schema).items():
32+
if not check_permission(read_permissions.get(name), self.context):
33+
continue
34+
serializer = getMultiAdapter(
35+
(field, self.context, self.request), IFieldSerializer
36+
)
37+
value = serializer()
38+
result[json_compatible(name)] = value
39+
40+
return result
41+
42+
43+
def check_permission(permission_name, context) -> bool:
44+
if permission_name is None:
45+
return True
46+
47+
permission_cache = getattr(context, "_v_permission_cache", {})
48+
if not permission_cache:
49+
context._v_permission_cache = permission_cache
50+
51+
if permission_name not in permission_cache:
52+
permission = queryUtility(IPermission, name=permission_name)
53+
if permission is None:
54+
permission_cache[permission_name] = True
55+
else:
56+
sm = getSecurityManager()
57+
permission_cache[permission_name] = bool(
58+
sm.checkPermission(permission.title, context)
59+
)
60+
return permission_cache[permission_name]

src/plone/restapi/serializer/site.py

+7-37
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,24 @@
1-
from AccessControl import getSecurityManager
21
from importlib import import_module
3-
from plone.autoform.interfaces import READ_PERMISSIONS_KEY
42
from plone.dexterity.utils import iterSchemata
53
from plone.restapi.batching import HypermediaBatch
64
from plone.restapi.bbb import IPloneSiteRoot
75
from plone.restapi.blocks import iter_block_transform_handlers
86
from plone.restapi.blocks import visit_blocks
97
from plone.restapi.interfaces import IBlockFieldSerializationTransformer
10-
from plone.restapi.interfaces import IFieldSerializer
8+
from plone.restapi.interfaces import ISchemaSerializer
119
from plone.restapi.interfaces import ISerializeToJson
1210
from plone.restapi.interfaces import ISerializeToJsonSummary
13-
from plone.restapi.serializer.converters import json_compatible
1411
from plone.restapi.serializer.dxcontent import get_allow_discussion_value
1512
from plone.restapi.serializer.dxcontent import update_with_working_copy_info
1613
from plone.restapi.serializer.expansion import expandable_elements
14+
from plone.restapi.serializer.schema import check_permission as _check_permission
1715
from plone.restapi.serializer.utils import get_portal_type_title
1816
from plone.restapi.services.locking import lock_info
19-
from plone.supermodel.utils import mergedTaggedValueDict
2017
from Products.CMFCore.utils import getToolByName
2118
from zope.component import adapter
2219
from zope.component import getMultiAdapter
23-
from zope.component import queryMultiAdapter
24-
from zope.component import queryUtility
2520
from zope.interface import implementer
2621
from zope.interface import Interface
27-
from zope.schema import getFields
28-
from zope.security.interfaces import IPermission
2922

3023
import json
3124

@@ -41,7 +34,6 @@ class SerializeSiteRootToJson:
4134
def __init__(self, context, request):
4235
self.context = context
4336
self.request = request
44-
self.permission_cache = {}
4537

4638
def _build_query(self):
4739
path = "/".join(self.context.getPhysicalPath())
@@ -88,20 +80,10 @@ def __call__(self, version=None):
8880

8981
# Insert Plone Site DX root field values
9082
for schema in iterSchemata(self.context):
91-
read_permissions = mergedTaggedValueDict(schema, READ_PERMISSIONS_KEY)
92-
93-
for name, field in getFields(schema).items():
94-
if not self.check_permission(
95-
read_permissions.get(name), self.context
96-
):
97-
continue
98-
99-
# serialize the field
100-
serializer = queryMultiAdapter(
101-
(field, self.context, self.request), IFieldSerializer
102-
)
103-
value = serializer()
104-
result[json_compatible(name)] = value
83+
schema_serializer = getMultiAdapter(
84+
(schema, self.context, self.request), ISchemaSerializer
85+
)
86+
result.update(schema_serializer())
10587

10688
# Insert locking information
10789
result.update({"lock": lock_info(self.context)})
@@ -133,19 +115,7 @@ def __call__(self, version=None):
133115
return result
134116

135117
def check_permission(self, permission_name, obj):
136-
if permission_name is None:
137-
return True
138-
139-
if permission_name not in self.permission_cache:
140-
permission = queryUtility(IPermission, name=permission_name)
141-
if permission is None:
142-
self.permission_cache[permission_name] = True
143-
else:
144-
sm = getSecurityManager()
145-
self.permission_cache[permission_name] = bool(
146-
sm.checkPermission(permission.title, obj)
147-
)
148-
return self.permission_cache[permission_name]
118+
return _check_permission(permission_name, obj)
149119

150120
def serialize_blocks(self):
151121
# This is only for below 6

0 commit comments

Comments
 (0)