From ba3713aa952fca5d5b146183108e1856fdfca8a8 Mon Sep 17 00:00:00 2001 From: Thanavath Jaroenvanit Date: Wed, 14 Mar 2018 14:40:13 -0600 Subject: [PATCH 1/2] Add support for PATCH method --- pyactiveresource/activeresource.py | 58 +++++++++++++++++++++++++++--- pyactiveresource/connection.py | 14 +++++++- test/activeresource_test.py | 53 +++++++++++++++++++++++++-- test/connection_test.py | 13 +++++++ 4 files changed, 131 insertions(+), 7 deletions(-) diff --git a/pyactiveresource/activeresource.py b/pyactiveresource/activeresource.py index 4121d16..ee207eb 100644 --- a/pyactiveresource/activeresource.py +++ b/pyactiveresource/activeresource.py @@ -449,6 +449,19 @@ 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). + """ + resource = cls({"id": id_}) + resource.save(attributes) + return resource + # Non-public class methods to support the above @classmethod def _split_options(cls, options): @@ -676,6 +689,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. @@ -799,11 +826,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 object is returned by the server). @@ -813,10 +840,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), @@ -1114,6 +1150,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. @@ -1142,5 +1191,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') diff --git a/pyactiveresource/connection.py b/pyactiveresource/connection.py index 721ceb9..5ea1c1f 100644 --- a/pyactiveresource/connection.py +++ b/pyactiveresource/connection.py @@ -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') @@ -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. diff --git a/test/activeresource_test.py b/test/activeresource_test.py index e7594a8..3aac253 100644 --- a/test/activeresource_test.py +++ b/test/activeresource_test.py @@ -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'} @@ -251,6 +252,10 @@ 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) @@ -258,6 +263,10 @@ def test_save(self): 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. @@ -268,6 +277,10 @@ 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) @@ -275,6 +288,10 @@ def test_save_xml_format(self): 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( @@ -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'') @@ -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) @@ -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'') diff --git a/test/connection_test.py b/test/connection_test.py index d66dbf6..99199fe 100644 --- a/test/connection_test.py +++ b/test/connection_test.py @@ -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', {}, '') From 47eb810ff382e6e70fb367a18052819c2f285c47 Mon Sep 17 00:00:00 2001 From: Thanavath Jaroenvanit Date: Wed, 14 Mar 2018 14:47:35 -0600 Subject: [PATCH 2/2] update readme --- README.md | 18 ++++++++++++++++++ pyactiveresource/activeresource.py | 6 ++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 733d336..e7258c4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/pyactiveresource/activeresource.py b/pyactiveresource/activeresource.py index ee207eb..1a0854f 100644 --- a/pyactiveresource/activeresource.py +++ b/pyactiveresource/activeresource.py @@ -458,8 +458,10 @@ def update(cls, id_, attributes): Returns: The updated resource (which may or may not have been updated successfully). """ - resource = cls({"id": id_}) - resource.save(attributes) + attrs = {"id": id_} + attrs.update(attributes) + resource = cls(attrs) + resource.save(attrs) return resource # Non-public class methods to support the above