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

Support null in content PATCH to delete a field value #187 #513

Merged
merged 5 commits into from Mar 13, 2018
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGES.rst
Expand Up @@ -4,7 +4,9 @@ Changelog
1.3.1 (unreleased)
------------------

- Nothing changed yet.
- Support null in content PATCH requests to delete a field value
(dexterity only). Fixes #187.
[csenger]


1.3.0 (2018-03-05)
Expand Down
8 changes: 7 additions & 1 deletion docs/source/content.rst
Expand Up @@ -176,7 +176,13 @@ Updating a Resource with PATCH
------------------------------

To update an existing resource we send a PATCH request to the server.
PATCH allows to provide just a subset of the resource (the values you actually want to change):
PATCH allows to provide just a subset of the resource
(the values you actually want to change).

If you send the value ``null`` for a field, the field's content will be
deleted and the ``missing_value`` defined for the field in the schema
will be set. Note that this is not possible if the field is ``required``,
and it only works for Dexterity types, not Archetypes:

.. http:example:: curl httpie python-requests
:request: _json/content_patch.req
Expand Down
15 changes: 15 additions & 0 deletions src/plone/restapi/deserializer/dxcontent.py
Expand Up @@ -62,6 +62,21 @@ def __call__(self, validate_all=False, data=None): # noqa: ignore=C901
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:
dm.set(field.missing_value)
else:
errors.append({
'field': field.__name__,
'message': (
'{} is a required field.'.format(
field.__name__
),
'Setting it to null is not allowed.'
)})
continue

# Deserialize to field value
deserializer = queryMultiAdapter(
(field, self.context, self.request),
Expand Down
7 changes: 7 additions & 0 deletions src/plone/restapi/tests/dxtypes.py
Expand Up @@ -118,6 +118,13 @@ class IDXTestDocumentSchema(model.Schema):
test_invariant_field1 = schema.TextLine(required=False)
test_invariant_field2 = schema.TextLine(required=False)

test_missing_value_field = schema.TextLine(required=False,
missing_value=u'missing',
default=u'default')

test_missing_value_required_field = schema.TextLine(
required=True, missing_value=u'missing', default=u'some value')

@invariant
def validate_same_value(data):
if data.test_invariant_field1 != data.test_invariant_field2:
Expand Down
38 changes: 37 additions & 1 deletion src/plone/restapi/tests/test_content_patch.py
Expand Up @@ -27,7 +27,8 @@ def setUp(self):
self.portal.invokeFactory(
'Document',
id='doc1',
title='My Document'
title='My Document',
description='Some Description'
)
wftool = getToolByName(self.portal, 'portal_workflow')
wftool.doActionFor(self.portal.doc1, 'publish')
Expand All @@ -44,6 +45,41 @@ def test_patch_document(self):
transaction.begin()
self.assertEqual("Patched Document", self.portal.doc1.Title())

def test_patch_document_will_delete_value_with_null(self):
self.assertEqual(self.portal.doc1.description, 'Some Description')
response = requests.patch(
self.portal.doc1.absolute_url(),
headers={'Accept': 'application/json'},
auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD),
data='{"description": null}',
)
transaction.commit()

# null will set field.missing_value which is u'' for the field
self.assertEqual(204, response.status_code)
self.assertEqual(u'', self.portal.doc1.description)

def test_patch_document_will_not_delete_value_with_null_if_required(self):
response = requests.patch(
self.portal.doc1.absolute_url(),
headers={'Accept': 'application/json'},
auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD),
data='{"title": null}',
)
transaction.commit()

# null will set field.missing_value which is u'' for the field
self.assertEqual(400, response.status_code)
self.assertTrue("\'field\': \'title\'" in response.text)
self.assertTrue(
'title is a required field.'
in response.text
)
self.assertTrue(
'Setting it to null is not allowed.'
in response.text
)

def test_patch_document_with_representation(self):
response = requests.patch(
self.portal.doc1.absolute_url(),
Expand Down
24 changes: 24 additions & 0 deletions src/plone/restapi/tests/test_dxcontent_deserializer.py
Expand Up @@ -162,6 +162,30 @@ def test_deserializer_passes_validation_with_not_provided_defaults(self):
self.assertEquals(u'DefaultFactory',
self.portal.doc1.test_default_factory_field)

def test_deserializer_sets_missing_value_when_receiving_null(self):
self.deserialize(body='{"test_missing_value_field": null}')
self.assertEquals(u'missing',
self.portal.doc1.test_missing_value_field)

def test_deserializer_sets_missing_value_on_required_field(self):
'''We don't set missing_value if the field is required'''
self.deserialize(
body='{"test_missing_value_required_field": "valid value"}')
with self.assertRaises(BadRequest) as cm:
self.deserialize(
body='{"test_missing_value_required_field": null}')
self.assertEquals(u'valid value',
self.portal.doc1.test_missing_value_required_field)
self.assertEquals(
(
'test_missing_value_required_field is a required field.',
'Setting it to null is not allowed.'
),
cm.exception.message[0]['message']
)
self.assertEquals(u'test_missing_value_required_field',
cm.exception.message[0]['field'])

def test_set_layout(self):
current_layout = self.portal.doc1.getLayout()
self.assertNotEquals(current_layout, "my_new_layout")
Expand Down