From 92d563ea8dd0f132efc853e6d4fd45725ab6b20c Mon Sep 17 00:00:00 2001
From: Dylan Smith <Dylan.Smith@shopify.com>
Date: Sat, 21 Apr 2012 20:40:24 -0400
Subject: [PATCH 3/3] Add JSON support.

---
 src/activeresource.py            |    6 ++++
 src/formats.py                   |   32 +++++++++++++++--
 src/tests/activeresource_test.py |   39 +++++++++++++++++++++
 src/tests/connection_test.py     |   23 +++++++++++--
 src/tests/util_test.py           |   70 ++++++++++++++++++++++++++++++++++++++
 src/util.py                      |   33 ++++++++++++++++++
 6 files changed, 198 insertions(+), 5 deletions(-)

diff --git a/src/activeresource.py b/src/activeresource.py
index 4bae5e5..23a65b1 100755
--- a/src/activeresource.py
+++ b/src/activeresource.py
@@ -745,6 +745,12 @@ class ActiveResource(object):
                            header=header, pretty=pretty,
                            dasherize=dasherize)
 
+    def to_json(self, root=True):
+        """Convert the object to a json string."""
+        if root == True:
+            root = self._singular
+        return util.to_json(self.to_dict(), root=root)
+
     def reload(self):
         """Connect to the server and update this resource's attributes.
 
diff --git a/src/formats.py b/src/formats.py
index 3e7576c..5745367 100644
--- a/src/formats.py
+++ b/src/formats.py
@@ -8,6 +8,10 @@ __author__ = 'Mark Roach (mrroach@google.com)'
 import logging
 from pyactiveresource import util
 
+def remove_root(data):
+    if isinstance(data, dict) and len(data) == 1:
+        return data.values()[0]
+    return data
 
 class Error(Exception):
     """Base exception type for this module."""
@@ -32,6 +36,28 @@ class XMLFormat(Base):
             data = util.xml_to_dict(resource_string, saveroot=False)
         except util.Error, err:
             raise Error(err)
-        if isinstance(data, dict) and len(data) == 1:
-            data = data.values()[0]
-        return data
+        return remove_root(data)
+
+class JSONFormat(Base):
+    """Encode and Decode JSON formatted ActiveResource objects."""
+
+    extension = 'json'
+    mime_type = 'application/json'
+
+    @staticmethod
+    def decode(resource_string):
+        """Convert a resource string to a dictionary."""
+        log = logging.getLogger('pyactiveresource.format')
+        log.debug('decoding resource: %s', resource_string)
+        try:
+            data = util.json_to_dict(resource_string)
+        except ValueError, err:
+            raise Error(err)
+        return remove_root(data)
+
+    @staticmethod
+    def encode(data):
+        """Convert a dictionary to a resource string."""
+        log = logging.getLogger('pyactiveresource.format')
+        log.debug('encoding resource: %r', data)
+        return util.to_json(data)
diff --git a/src/tests/activeresource_test.py b/src/tests/activeresource_test.py
index 7de0b2d..6a1ca6c 100644
--- a/src/tests/activeresource_test.py
+++ b/src/tests/activeresource_test.py
@@ -10,6 +10,7 @@ import pickle
 import urllib
 from pyactiveresource import activeresource
 from pyactiveresource import connection
+from pyactiveresource import formats
 from pyactiveresource import util
 from pyactiveresource.tests import http_fake
 
@@ -31,6 +32,7 @@ class ActiveResourceTest(unittest.TestCase):
         self.general_store = {'id': 1, 'name': 'General Store'}
         self.store_update = {'manager_id': 3, 'id': 1, 'name':'General Store'}
         self.xml_headers = {'Content-type': 'application/xml'}
+        self.json_headers = {'Content-type': 'application/json'}
 
         self.matz  = util.to_xml(
                 {'id': 1, 'name': 'Matz'}, root='person')
@@ -88,6 +90,17 @@ class ActiveResourceTest(unittest.TestCase):
         self.assertEqual([self.arnold, self.eb],
                          [p.attributes for p in people])
 
+    def test_find_with_json_format(self):
+        # Return a list of people for a find method call
+        self.http.respond_to(
+            'GET', '/people.json', {},
+            util.to_json([self.arnold, self.eb], root='people'))
+
+        self.person.format = formats.JSONFormat
+        people = self.person.find()
+        self.assertEqual([self.arnold, self.eb],
+                         [p.attributes for p in people])
+
     def test_find_parses_non_array_collection(self):
         collection_xml = '''<people>
                 <person><name>bob</name><id>1</id></person>
@@ -113,6 +126,15 @@ class ActiveResourceTest(unittest.TestCase):
         arnold = self.person.find(1)
         self.assertEqual(self.arnold, arnold.attributes)
 
+    def test_find_by_id_with_json_format(self):
+        # Return a single person for a find(id=<id>) call
+        self.http.respond_to(
+            'GET', '/people/1.json', {}, util.to_json(self.arnold, root='person'))
+
+        self.person.format = formats.JSONFormat
+        arnold = self.person.find(1)
+        self.assertEqual(self.arnold, arnold.attributes)
+
     def test_reload(self):
         self.http.respond_to(
             'GET', '/people/1.xml', {}, util.to_xml(self.arnold, root='person'))
@@ -227,6 +249,23 @@ class ActiveResourceTest(unittest.TestCase):
         store.manager_id = 3
         store.save()
 
+    def test_save_with_json_format(self):
+        # Return an object with id for a post(save) request.
+        self.http.respond_to(
+            'POST', '/stores.json', self.json_headers,
+            util.to_json(self.general_store))
+        # Return an object for a put request.
+        self.http.respond_to(
+            'PUT', '/stores/1.json', self.json_headers,
+            util.to_json(self.store_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()
+
     def test_save_should_clear_errors(self):
       self.http.respond_to(
           'POST', '/stores.xml', self.xml_headers,
diff --git a/src/tests/connection_test.py b/src/tests/connection_test.py
index 5cdd44e..835755c 100644
--- a/src/tests/connection_test.py
+++ b/src/tests/connection_test.py
@@ -11,6 +11,7 @@ import urllib2
 from StringIO import StringIO
 from pyactiveresource import connection
 from pyactiveresource import util
+from pyactiveresource import formats
 from pyactiveresource.tests import http_fake
 
 
@@ -114,6 +115,14 @@ class ConnectionTest(unittest.TestCase):
         self.http.respond_to('HEAD', 'http://localhost/people/1.xml', {}, '')
         self.assertFalse(self.connection.head('/people/1.xml').body)
 
+    def test_get_with_json_format(self):
+        person = util.to_json({'id': 1, 'name': 'Matz'}, root='person')
+        self.http.respond_to(
+            'GET', 'http://localhost/people/1.json', {}, person)
+        self.connection.format = formats.JSONFormat
+        response = self.connection.get('/people/1.json')
+        self.assertEqual(response['name'], 'Matz')
+
     def test_get_with_header(self):
         self.http.respond_to(
             'GET', 'http://localhost/people/2.xml', self.header, self.david)
@@ -144,7 +153,7 @@ class ConnectionTest(unittest.TestCase):
             '', 200, {'Location': '/people/5.xml'})
         response = self.connection.post('/people.xml')
         self.assertEqual('/people/5.xml', response['Location'])
-  
+
     def test_post_with_header(self):
         header = self.header
         header.update(self.zero_length_content_headers)
@@ -153,7 +162,17 @@ class ConnectionTest(unittest.TestCase):
             '', 201, {'Location': '/people/6.xml'})
         response = self.connection.post('/members.xml', self.header)
         self.assertEqual('/people/6.xml', response['Location'])
-  
+
+    def test_post_with_json_format(self):
+        content_headers = {'Content-Length': '0',
+                           'Content-Type': 'application/json'}
+        self.http.respond_to(
+            'POST', '/people.json', content_headers,
+            '', 200, {'Location': '/people/5.json'})
+        self.connection.format = formats.JSONFormat
+        response = self.connection.post('/people.json')
+        self.assertEqual('/people/5.json', response['Location'])
+
     def test_put(self):
         self.http.respond_to('PUT', '/people/1.xml',
                              self.zero_length_content_headers, '', 204)
diff --git a/src/tests/util_test.py b/src/tests/util_test.py
index 7e6e33e..f2ec5e5 100644
--- a/src/tests/util_test.py
+++ b/src/tests/util_test.py
@@ -315,6 +315,76 @@ class UtilTest(unittest.TestCase):
         query = util.to_query({'var': {'a': 1, 'b':{'c': 2}}})
         self.assertEqual(set(['var%5Ba%5D=1', 'var%5Bb%5D%5Bc%5D=2']), set(query.split('&')))
 
+    def test_json_to_dict_single_record(self):
+        """Test the json_to_dict function."""
+        topic_json = '''{
+              "topic": {
+                "title": "The First Topic",
+                "author_name": "David",
+                "id": 1,
+                "approved": true,
+                "replies_count": 0,
+                "replies_close_in": 2592000000,
+                "written_on": "2003-07-16",
+                "viewed_at": "2003-07-16T09:28:00+0000",
+                "content": "--- \\n1: should be an integer\\n:message: Have a nice day\\narray: \\n- should-have-dashes: true\\n  should_have_underscores: true\\n",
+                "author_email_address": "david@loudthinking.com",
+                "parent_id": null,
+                "ad_revenue": 1.5,
+                "optimum_viewing_angle": 135.0,
+                "resident": "yes"
+              }
+            }'''
+
+        expected_topic_dict = {
+            'title': 'The First Topic',
+            'author_name': 'David',
+            'id': 1,
+            'approved': True,
+            'replies_count': 0,
+            'replies_close_in': 2592000000L,
+            'written_on': "2003-07-16",
+            'viewed_at': "2003-07-16T09:28:00+0000",
+            'content': "--- \n1: should be an integer\n:message: Have a nice day\narray: \n- should-have-dashes: true\n  should_have_underscores: true\n",
+            'author_email_address': 'david@loudthinking.com',
+            'parent_id': None,
+            'ad_revenue': 1.5,
+            'optimum_viewing_angle': 135.0,
+            'resident': 'yes'
+            }
+
+        self.assertEqual(expected_topic_dict,
+                         util.json_to_dict(topic_json)['topic'])
+
+    def test_json_to_dict_multiple_records(self):
+        """Test the json to dict function."""
+        topics_json = '''{
+             "topics": [
+               { "title": "The First Topic" },
+               { "title": "The Second Topic" }
+             ]
+           }'''
+
+        expected_topic_dicts = [
+              { 'title': 'The First Topic' },
+              { 'title': 'The Second Topic' },
+            ]
+
+        self.assertEqual(expected_topic_dicts,
+                         util.json_to_dict(topics_json)['topics'])
+
+    def test_to_json_should_allow_unicode(self):
+        json = util.to_json({'data': u'\xe9'})
+        self.assert_('\u00e9' in json)
+
+    def test_to_json_should_allow_utf8_encoded_strings(self):
+        json = util.to_json({'data': u'\xe9'.encode('utf-8')})
+        self.assert_('\u00e9' in json)
+
+    def test_to_json_with_root(self):
+        xml = util.to_xml({'title': 'Test'}, root='product')
+        self.assert_('product' in xml)
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/src/util.py b/src/util.py
index b482145..f3801e0 100644
--- a/src/util.py
+++ b/src/util.py
@@ -19,6 +19,14 @@ except ImportError:
     yaml = None
 
 try:
+    import simplejson as json
+except ImportError:
+    try:
+        import json
+    except ImportError:
+        json = None
+
+try:
     from dateutil.parser import parse as date_parse
 except ImportError:
     try:
@@ -273,6 +281,31 @@ def serialize(value, element):
             break
 
 
+def to_json(obj, root='object'):
+    """Convert a dictionary or list to an JSON string.
+
+    Args:
+        obj: The object to serialize.
+
+    Returns:
+        A json string.
+    """
+    if root:
+        obj = { root: obj }
+    return json.dumps(obj)
+
+
+def json_to_dict(jsonstr):
+    """Parse the json into a dictionary of attributes.
+
+    Args:
+        jsonstr: A JSON formatted string.
+    Returns:
+        The deserialized object.
+    """
+    return json.loads(jsonstr)
+
+
 def to_xml(obj, root='object', pretty=False, header=True, dasherize=True):
     """Convert a dictionary or list to an XML string.
 
-- 
1.7.10

