Skip to content

Add patch method support #23

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

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,24 @@ tyler.first = 'Tyson'
tyler.save() # true
```

Partial Update
------
'save' is also used to partially update an existing resource. If a list of attributes is passed in as argument
```
# {"first":"Tyler"}
#
# is submitted as the body on
#
# PATCH http://api.people.com:3000/people/1.json
#
# when save is called on an existing Person object. An empty response is
# is expected with code (204)
#
tyler.first = 'Tyson'
tyler.last = 'Johnson'
tyler.save(['first']) # true
```

Delete
-----
Destruction of a resource can be invoked as a class and instance method of the resource.
Expand Down
60 changes: 56 additions & 4 deletions pyactiveresource/activeresource.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,21 @@ def create(cls, attributes):
resource.save()
return resource

@classmethod
def update(cls, id_, attributes):
"""Update an existing resource with the given attributes.

Args:
attributes: A dictionary of attributes to update.
Returns:
The updated resource (which may or may not have been updated successfully).
"""
attrs = {"id": id_}
attrs.update(attributes)
resource = cls(attrs)
resource.save(attrs)
return resource

# Non-public class methods to support the above
@classmethod
def _split_options(cls, options):
Expand Down Expand Up @@ -676,6 +691,20 @@ def _class_put(cls, method_name, body=b'', **kwargs):
url = cls._custom_method_collection_url(method_name, kwargs)
return cls.connection.put(url, cls.headers, body)

@classmethod
def _class_patch(cls, method_name, body=b'', **kwargs):
"""Update a nested resource or resources.

Args:
method_name: the nested resource to update.
body: The data to send as the body of the request.
kwargs: Any keyword arguments for the query.
Returns:
A connection.Response object.
"""
url = cls._custom_method_collection_url(method_name, kwargs)
return cls.connection.patch(url, cls.headers, body)

@classmethod
def _class_delete(cls, method_name, **kwargs):
"""Delete a nested resource or resources.
Expand Down Expand Up @@ -799,11 +828,11 @@ def reload(self):
self.klass.headers)
self._update(attributes)

def save(self):
def save(self, attributes=None):
"""Save the object to the server.

Args:
None
attributes: If defines, list of object attributes for partial update (PATCH).
Returns:
True on success, False on ResourceInvalid errors (sets the errors
attribute if an <errors> object is returned by the server).
Expand All @@ -813,10 +842,19 @@ def save(self):
try:
self.errors.clear()
if self.id:
response = self.klass.connection.put(
if attributes:
data = [(attr, getattr(self, attr)) for attr in attributes]
data = dict((k, v) for k, v in data)
payload = self.klass(data).encode()
response = self.klass.connection.patch(
self._element_path(self.id, self._prefix_options),
self.klass.headers,
data=self.encode())
data=payload)
else:
response = self.klass.connection.put(
self._element_path(self.id, self._prefix_options),
self.klass.headers,
data=self.encode())
else:
response = self.klass.connection.post(
self._collection_path(self._prefix_options),
Expand Down Expand Up @@ -1114,6 +1152,19 @@ def _instance_put(self, method_name, body=b'', **kwargs):
url = self._custom_method_element_url(method_name, kwargs)
return self.klass.connection.put(url, self.klass.headers, body)

def _instance_patch(self, method_name, body=b'', **kwargs):
"""Update a nested resource.

Args:
method_name: the nested resource to update.
body: The data to send as the body of the request.
kwargs: Any keyword arguments for the query.
Returns:
A connection.Response object.
"""
url = self._custom_method_element_url(method_name, kwargs)
return self.klass.connection.patch(url, self.klass.headers, body)

def _instance_delete(self, method_name, **kwargs):
"""Delete a nested resource or resources.

Expand Down Expand Up @@ -1142,5 +1193,6 @@ def _instance_head(self, method_name, **kwargs):
get = ClassAndInstanceMethod('_class_get', '_instance_get')
post = ClassAndInstanceMethod('_class_post', '_instance_post')
put = ClassAndInstanceMethod('_class_put', '_instance_put')
patch = ClassAndInstanceMethod('_class_patch', '_instance_patch')
delete = ClassAndInstanceMethod('_class_delete', '_instance_delete')
head = ClassAndInstanceMethod('_class_head', '_instance_head')
14 changes: 13 additions & 1 deletion pyactiveresource/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ def _open(self, method, path, headers=None, data=None):
request.add_header('Content-Type', self.format.mime_type)
request.data = data
self.log.debug('request-body:%s', request.data)
elif method in ['POST', 'PUT']:
elif method in ['POST', 'PUT', 'PATCH']:
# Some web servers need a content length on all POST/PUT operations
request.add_header('Content-Type', self.format.mime_type)
request.add_header('Content-Length', '0')
Expand Down Expand Up @@ -351,6 +351,18 @@ def put(self, path, headers=None, data=None):
"""
return self._open('PUT', path, headers=headers, data=data)

def patch(self, path, headers=None, data=None):
"""Perform an HTTP patch request.

Args:
path: The HTTP path to retrieve.
headers: A dictionary of HTTP headers to add.
data: The data to send as the body of the request.
Returns:
A Response object.
"""
return self._open('PATCH', path, headers=headers, data=data)

def post(self, path, headers=None, data=None):
"""Perform an HTTP post request.

Expand Down
53 changes: 51 additions & 2 deletions test/activeresource_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ def setUp(self):
self.soup = {'id': 1, 'name': 'Hot Water Soup'}
self.store_new = {'name': 'General Store'}
self.general_store = {'id': 1, 'name': 'General Store'}
self.store_update = {'manager_id': 3, 'id': 1, 'name':'General Store'}
self.store_update = {'manager_id': 3, 'id': 1, 'name': 'General Store'}
self.store_partial_update = {'manager_id': 3, 'id': 1, 'name': 'New General Store Name'}
self.xml_headers = {'Content-type': 'application/xml'}
self.json_headers = {'Content-type': 'application/json'}

Expand Down Expand Up @@ -251,13 +252,21 @@ def test_save(self):
self.http.respond_to(
'PUT', '/stores/1.json', self.json_headers,
util.to_json(self.store_update, root='store'))
# Return an object for a patch request.
self.http.respond_to(
'PATCH', '/stores/1.json', self.json_headers,
util.to_json(self.store_partial_update, root='store'))

self.store.format = formats.JSONFormat
store = self.store(self.store_new)
store.save()
self.assertEqual(self.general_store, store.attributes)
store.manager_id = 3
store.save()
self.assertEqual(self.store_update, store.attributes)
store.name = "New General Store Name"
store.save(["name"])
self.assertEqual(self.store_partial_update, store.attributes)

def test_save_xml_format(self):
# Return an object with id for a post(save) request.
Expand All @@ -268,13 +277,21 @@ def test_save_xml_format(self):
self.http.respond_to(
'PUT', '/stores/1.xml', self.xml_headers,
util.to_xml(self.store_update, root='store'))
# Return an object for a patch request.
self.http.respond_to(
'PATCH', '/stores/1.xml', self.xml_headers,
util.to_xml(self.store_partial_update, root='store'))

self.store.format = formats.XMLFormat
store = self.store(self.store_new)
store.save()
self.assertEqual(self.general_store, store.attributes)
store.manager_id = 3
store.save()
self.assertEqual(self.store_update, store.attributes)
store.name = "New General Store Name"
store.save(["name"])
self.assertEqual(self.store_partial_update, store.attributes)

def test_save_should_clear_errors(self):
self.http.respond_to(
Expand Down Expand Up @@ -327,6 +344,18 @@ def test_class_put_nested(self):
self.assertEqual(connection.Response(200, b''),
self.address.put('sort', person_id=1, by='name'))

def test_class_patch(self):
self.http.respond_to('PATCH', '/people/partial_update.json?name=Matz',
self.json_headers, b'')
self.assertEqual(connection.Response(200, b''),
self.person.patch('partial_update', b'atestbody', name='Matz'))

def test_class_patch_nested(self):
self.http.respond_to('PATCH', '/people/1/addresses/sort.json?by=name',
self.zero_length_content_headers, b'')
self.assertEqual(connection.Response(200, b''),
self.address.patch('sort', person_id=1, by='name'))

def test_class_delete(self):
self.http.respond_to('DELETE', '/people/deactivate.json?name=Matz',
{}, b'')
Expand Down Expand Up @@ -383,6 +412,27 @@ def test_instance_put_nested(self):
self.address.find(1, person_id=1).put('normalize_phone',
locale='US'))

def test_instance_patch(self):
self.http.respond_to('GET', '/people/1.json', {}, self.matz)
self.http.respond_to(
'PATCH', '/people/1/partial_update.json?position=Manager',
self.json_headers, b'')
self.assertEqual(
connection.Response(200, b''),
self.person.find(1).patch('partial_update', b'body', position='Manager'))

def test_instance_patch_nested(self):
self.http.respond_to(
'GET', '/people/1/addresses/1.json', {}, self.addy)
self.http.respond_to(
'PATCH', '/people/1/addresses/1/normalize_phone.json?locale=US',
self.zero_length_content_headers, b'', 204)

self.assertEqual(
connection.Response(204, b''),
self.address.find(1, person_id=1).patch('normalize_phone',
locale='US'))

def test_instance_get_nested(self):
self.http.respond_to(
'GET', '/people/1/addresses/1.json', {}, self.addy)
Expand All @@ -391,7 +441,6 @@ def test_instance_get_nested(self):
self.assertEqual({'id': 1, 'street': '12345 Street', 'zip': "27519" },
self.address.find(1, person_id=1).get('deep'))


def test_instance_delete(self):
self.http.respond_to('GET', '/people/1.json', {}, self.matz)
self.http.respond_to('DELETE', '/people/1/deactivate.json', {}, b'')
Expand Down
13 changes: 13 additions & 0 deletions test/connection_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,19 @@ def test_put_with_header(self):
self.http.respond_to('PUT', '/people/2.json', header, '', 204)
response = self.connection.put('/people/2.json', self.header)
self.assertEqual(204, response.code)

def test_patch(self):
self.http.respond_to('PATCH', '/people/1.json',
self.zero_length_content_headers, '', 204)
response = self.connection.patch('/people/1.json')
self.assertEqual(204, response.code)

def test_patch_with_header(self):
header = self.header
header.update(self.zero_length_content_headers)
self.http.respond_to('PATCH', '/people/2.json', header, '', 204)
response = self.connection.patch('/people/2.json', self.header)
self.assertEqual(204, response.code)

def test_delete(self):
self.http.respond_to('DELETE', '/people/1.json', {}, '')
Expand Down