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

Overhaul JSON schema generation for @types endpoint #237

Merged
merged 1 commit into from
Mar 5, 2017
Merged
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
4 changes: 4 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Changelog

Bugfixes:

- Overhaul JSON schema generation for @types endpoint. It now returns
fields in correct order and in their appropriate fieldsets.
[lgraf]

- Add missing id to the Plone site serialization, related to issue #186
[sneridagh]

Expand Down
26 changes: 19 additions & 7 deletions docs/source/_json/types_document.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,6 @@ content-type: application/json+schema
"fields": [
"title",
"description",
"subjects",
"language",
"effective",
"expires",
"creators",
"contributors",
"rights",
"text",
"changeNote"
],
Expand All @@ -34,10 +27,29 @@ content-type: application/json+schema
},
{
"fields": [
"subjects",
"language",
"relatedItems"
],
"id": "categorization",
"title": "Categorization"
},
{
"fields": [
"effective",
"expires"
],
"id": "dates",
"title": "Dates"
},
{
"fields": [
"creators",
"contributors",
"rights"
],
"id": "ownership",
"title": "Ownership"
}
],
"properties": {
Expand Down
8 changes: 5 additions & 3 deletions src/plone/restapi/tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@

from plone.restapi.testing import PLONE_RESTAPI_DX_INTEGRATION_TESTING
from plone.restapi.types.interfaces import IJsonSchemaProvider
from plone.restapi.types.utils import get_fields_from_schema
from plone.restapi.types.utils import get_fieldsets
from plone.restapi.types.utils import get_jsonschema_for_fti
from plone.restapi.types.utils import get_jsonschema_for_portal_type
from plone.restapi.types.utils import get_jsonschema_properties


class IDummySchema(model.Schema):
Expand Down Expand Up @@ -66,8 +67,9 @@ def setUp(self):
self.portal = self.layer['portal']
self.request = self.layer['request']

def test_get_fields_from_schema(self):
info = get_fields_from_schema(IDummySchema, self.portal, self.request)
def test_get_jsonschema_properties(self):
fieldsets = get_fieldsets(self.portal, self.request, IDummySchema)
info = get_jsonschema_properties(self.portal, self.request, fieldsets)
expected = {
'field1': {
'title': u'Foo',
Expand Down
9 changes: 6 additions & 3 deletions src/plone/restapi/types/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
from zope.schema.interfaces import IVocabularyFactory

from plone.restapi.types.interfaces import IJsonSchemaProvider
from plone.restapi.types.utils import get_fields_from_schema
from plone.restapi.types.utils import get_fieldsets
from plone.restapi.types.utils import get_jsonschema_properties


@adapter(IField, Interface, Interface)
Expand Down Expand Up @@ -271,8 +272,10 @@ def get_properties(self):
else:
prefix = self.field.__name__

return get_fields_from_schema(
self.field.schema, self.context, self.request, prefix)
context = self.context
request = self.request
fieldsets = get_fieldsets(context, request, self.field.schema)
return get_jsonschema_properties(context, request, fieldsets, prefix)

def additional(self):
info = super(ObjectJsonSchemaProvider, self).additional()
Expand Down
215 changes: 114 additions & 101 deletions src/plone/restapi/types/utils.py
Original file line number Diff line number Diff line change
@@ -1,145 +1,158 @@
# -*- coding: utf-8 -*-
"""Utils for jsonschema."""
from collections import OrderedDict
"""Utils to translate FTIs / zope.schema interfaces to JSON schemas.

from zope.component import getUtility
from zope.component import getMultiAdapter
from zope.globalrequest import getRequest
from zope.i18n import translate
from zope.schema import getFieldsInOrder
The basic idea here is to instantiate a minimal z3c form, and then have
plone.autoform work its magic on it to process all the fields, and apply
any p.autoform directives (fieldsets, field modes, omitted fields, field
permissions, widgets).

from plone.autoform.interfaces import MODES_KEY
from plone.autoform.interfaces import IFormFieldProvider
from plone.autoform.utils import mergedTaggedValuesForForm
from plone.behavior.interfaces import IBehavior
from plone.supermodel.interfaces import FIELDSETS_KEY
Also schema interface inheritance (IRO) and additional schemata from behaviors
are factored into how the final resulting fieldsets are composed.

from Products.CMFCore.utils import getToolByName
This approach should ensure that all these directives get respected and
processed the same way they would for a server-rendered form.
"""

from collections import OrderedDict
from copy import copy
from plone.autoform.form import AutoExtensibleForm
from plone.dexterity.utils import getAdditionalSchemata
from plone.restapi.types.interfaces import IJsonSchemaProvider
from Products.CMFCore.utils import getToolByName
from z3c.form import form as z3c_form
from zope.component import getMultiAdapter
from zope.globalrequest import getRequest
from zope.i18n import translate


def non_fieldset_fields(schema):
fieldset_fields = []
fieldsets = schema.queryTaggedValue(FIELDSETS_KEY, [])
def create_form(context, request, base_schema, additional_schemata=None):
"""Create a minimal, standalone z3c form and run the field processing
logic of plone.autoform on it.
"""
if additional_schemata is None:
additional_schemata = ()

for fieldset in fieldsets:
fieldset_fields.extend(fieldset.fields)
class SchemaForm(AutoExtensibleForm, z3c_form.AddForm):
schema = base_schema
additionalSchemata = additional_schemata
ignoreContext = True

fields = [info[0] for info in getFieldsInOrder(schema)]
return [f for f in fields if f not in fieldset_fields]
form = SchemaForm(context, request)
form.updateFieldsFromSchemata()
return form


def get_ordered_fields(fti):
# this code is much complicated because we have to get sure
# we get the fields in the order of the fieldsets
# the order of the fields in the fieldsets can differ
# of the getFieldsInOrder(schema) order...
# that's because fields from different schemas
# can take place in the same fieldset
schema = fti.lookupSchema()
fieldset_fields = {}
ordered_fieldsets = ['default']
labels = {'default': u'Default'}
for fieldset in schema.queryTaggedValue(FIELDSETS_KEY, []):
ordered_fieldsets.append(fieldset.__name__)
labels[fieldset.__name__] = fieldset.label
fieldset_fields[fieldset.__name__] = fieldset.fields

fieldset_fields['default'] = non_fieldset_fields(schema)

# Get the behavior fields
fields = getFieldsInOrder(schema)
for behavior_id in fti.behaviors:
schema = getUtility(IBehavior, behavior_id).interface
if not IFormFieldProvider.providedBy(schema):
continue

fields.extend(getFieldsInOrder(schema))
for fieldset in schema.queryTaggedValue(FIELDSETS_KEY, []):
fieldset_fields.setdefault(fieldset.__name__, []).extend(
fieldset.fields)
if fieldset.__name__ not in ordered_fieldsets:
ordered_fieldsets.append(fieldset.__name__)
labels[fieldset.__name__] = fieldset.label

fieldset_fields['default'].extend(non_fieldset_fields(schema))

ordered_fields = []
for fieldset in ordered_fieldsets:
ordered_fields.extend(fieldset_fields[fieldset])

ordered_fieldsets_fields = [{
'id': fieldset,
'fields': fieldset_fields[fieldset],
'title': labels[fieldset],
} for fieldset in ordered_fieldsets]

fields.sort(key=lambda field: ordered_fields.index(field[0]))
return (fields, ordered_fieldsets_fields)


def get_fields_from_schema(schema, context, request, prefix='',
excluded_fields=None):
"""Get jsonschema from zope schema."""
fields_info = OrderedDict()
def iter_fields(fieldsets):
"""Iterate over a flat list of fields, given a list of fieldset dicts
as returned by `get_fieldsets`.
"""
for fieldset in fieldsets:
for field in fieldset['fields']:
yield field


def get_fieldsets(context, request, schema, additional_schemata=None):
"""Given a base schema, and optionally some additional schemata,
build a list of fieldsets with the corresponding z3c.form fields in them.
"""
form = create_form(context, request, schema, additional_schemata)

# Default fieldset
fieldsets = [{
'id': 'default',
'title': u'Default',
'fields': form.fields.values(),
}]

# Additional fieldsets (AKA z3c.form groups)
for group in form.groups:
fieldset = {
'id': group.__name__,
'title': translate(group.label, context=getRequest()),
'fields': group.fields.values(),
}
fieldsets.append(fieldset)

return fieldsets


def get_fieldset_infos(fieldsets):
"""Given a list of fieldset dicts as returned by `get_fieldsets()`,
return a list of fieldset info dicts that contain the (short) field name
instead of the actual field instance.
"""
fieldset_infos = []
for fieldset in fieldsets:
fs_info = copy(fieldset)
fs_info['fields'] = [f.field.getName() for f in fs_info['fields']]
fieldset_infos.append(fs_info)
return fieldset_infos


def get_jsonschema_properties(context, request, fieldsets, prefix='',
excluded_fields=None):
"""Build a JSON schema 'properties' list, based on a list of fieldset
dicts as returned by `get_fieldsets()`.
"""
properties = OrderedDict()
if excluded_fields is None:
excluded_fields = []

for fieldname, field in getFieldsInOrder(schema):
for field in iter_fields(fieldsets):
fieldname = field.field.getName()
if fieldname not in excluded_fields:
adapter = getMultiAdapter(
(field, context, request),
(field.field, context, request),
interface=IJsonSchemaProvider)

adapter.prefix = prefix
if prefix:
fieldname = '.'.join([prefix, fieldname])

fields_info[fieldname] = adapter.get_schema()
properties[fieldname] = adapter.get_schema()

return fields_info
return properties


def get_jsonschema_for_fti(fti, context, request, excluded_fields=None):
"""Get jsonschema for given fti."""
fields_info = OrderedDict()
"""Build a complete JSON schema for the given FTI.
"""
if excluded_fields is None:
excluded_fields = []

schema = fti.lookupSchema()
additional_schemata = tuple(getAdditionalSchemata(portal_type=fti.id))

fieldsets = get_fieldsets(context, request, schema, additional_schemata)

# Build JSON schema properties
properties = get_jsonschema_properties(
context, request, fieldsets, excluded_fields=excluded_fields)

# Determine required fields
required = []
(ordered_fields, fieldsets) = get_ordered_fields(fti)
for fieldname, field in ordered_fields:
if fieldname not in excluded_fields:
adapter = getMultiAdapter(
(field, context, request),
interface=IJsonSchemaProvider)
# get name from z3c.form field to have full name (behavior)
fields_info[fieldname] = adapter.get_schema()
if field.required:
required.append(fieldname)

# look up hidden fields from plone.autoform tagged values
hidden_fields = mergedTaggedValuesForForm(
fti.lookupSchema(),
MODES_KEY,
[]
)
for field_title, mode_value in hidden_fields.items():
fields_info[field_title]['mode'] = mode_value
for field in iter_fields(fieldsets):
if field.field.required:
required.append(field.field.getName())

# Include field modes
for field in iter_fields(fieldsets):
if field.mode:
properties[field.field.getName()]['mode'] = field.mode

return {
'type': 'object',
'title': translate(fti.Title(), context=getRequest()),
'properties': fields_info,
'properties': properties,
'required': required,
'fieldsets': fieldsets,
'fieldsets': get_fieldset_infos(fieldsets),
}


def get_jsonschema_for_portal_type(portal_type, context, request,
excluded_fields=None):
"""Get jsonschema for given portal type name."""
"""Build a complete JSON schema for the given portal_type.
"""
ttool = getToolByName(context, 'portal_types')
fti = ttool[portal_type]
return get_jsonschema_for_fti(
Expand Down