Merge lp:~jameinel/maas/allow-json into lp:~maas-committers/maas/trunk

Proposed by John A Meinel
Status: Merged
Merged at revision: 1230
Proposed branch: lp:~jameinel/maas/allow-json
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 245 lines (+138/-5)
5 files modified
src/apiclient/encode_json.py (+32/-0)
src/apiclient/maas_client.py (+14/-4)
src/apiclient/testing/django.py (+26/-0)
src/apiclient/tests/test_encode_json.py (+36/-0)
src/apiclient/tests/test_maas_client.py (+30/-1)
To merge this branch: bzr merge lp:~jameinel/maas/allow-json
Reviewer Review Type Date Requested Status
John A Meinel (community) Approve
Gavin Panella (community) Approve
Review via email: mp+128512@code.launchpad.net

Commit message

Add MAASClient.post(as_json=True) to enable users of the client to post their data in a more efficient manner if the API on the other side supports it.

Testing shows that if the data being uploaded is large, you can see a big improvement in performance.

Description of the change

This updates MAASClient so that it can take an 'as_json' parameter.

This changes how the data gets POSTed, sending it as JSON encoding, rather than MimeMultipart.

In my testing, this is dramatically faster for the tag processing. (It drops the time to update 10,000 nodes from 45s down to 35s.)

I will be posting a follow up patch that does that code.

The main reason it is still a flag vs the default, is because the API side needs some updates to support this. It seems that most of our API code uses 'request.POST' but this comes in as 'request.data'. Also, multipart comes in as a multidict (mapping each parameter to a list) while if you pass a dict as json you get just a dict on the other side.

To post a comment you must log in.
Revision history for this message
John A Meinel (jameinel) wrote :

It looks like I accidentally included the API changes necessary to support POSTing as json to the tag-related APIs. I'm updating the branch now to remove them, but that change will be present in the next branch I propose.

Revision history for this message
Gavin Panella (allenap) wrote :

Looks good. I think [4] needs fixing, but it's minor, so +1.

[1]

+        :param as_json: Encode params as application/json instead of
+            application/x-www-form-urlencoded. Only use this if you know the
..
+        :param as_json: Instead of POSTing the content as multipart
+            x-www-form-urlencoded post it as application/json

I think this should be multipart/form-data for all non-GET calls.

[2]

+    def assertEncodeJSONData(self, expected_body, expected_headers, params):
+        self.assertEqual(
+            (expected_body, expected_headers),
+            encode_json_data(params))

Something I used for the first time earlier today might make failures
here prettier:

        expected = Equals((expected_body, expected_headers))
        self.assertThat(
            params, AfterPreprocessing(
                encode_json_data, Equals(expected)))

Up to you though; it's a small win.

[3]

+        self.assertEqual('application/json', headers.get('Content-Type'))
+        self.assertEqual(len(body), headers.get('Content-Length'))
+        self.assertEqual(params, json.loads(body))
+        data = parse_headers_and_body_with_mimer(headers, body)
+        self.assertEqual(params, data)

Elsewhere in MAAS it's been useful to use testtools' compound
matchers, so that failures contain more information about their
nature. Consider something like:

        observed = (
            headers.get('Content-Type'),
            headers.get('Content-Length'),
            body,
            )
        expected = (
            'application/json',
            "%d" % len(body),
            AfterPreprocessing(json.loads, Equals(params)),
            )
        self.assertThat(observed, MatchesListwise(expected))

[4]

+        self.assertEqual(len(body), headers.get('Content-Length'))

The header should be a string value, so check that it's being
populated correctly.

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :

The Jenkins job https://jenkins.qa.ubuntu.com/job/maas-merger-quantal/170/console reported an error when processing this lp:~jameinel/maas/allow-json branch.
Not merging it.

Revision history for this message
John A Meinel (jameinel) wrote :

Submitting again because I think I have a fix for failing tests that should have failed in the first submission.

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :

The Jenkins job https://jenkins.qa.ubuntu.com/job/maas-merger-quantal/172/console reported an error when processing this lp:~jameinel/maas/allow-json branch.
Not merging it.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== added file 'src/apiclient/encode_json.py'
--- src/apiclient/encode_json.py 1970-01-01 00:00:00 +0000
+++ src/apiclient/encode_json.py 2012-10-08 15:04:22 +0000
@@ -0,0 +1,32 @@
1# Copyright 2012 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Encoding requests as JSON data."""
5
6from __future__ import (
7 absolute_import,
8 print_function,
9 unicode_literals,
10 )
11
12__metaclass__ = type
13__all__ = [
14 'encode_json_data',
15 ]
16
17import json
18
19
20def encode_json_data(params):
21 """Encode params as JSON and set up headers for an HTTP POST.
22
23 :param params: Can be a dict or a list, but should generally be a dict, to
24 match the key-value data expected by most receiving APIs.
25 :return: (body, headers)
26 """
27 body = json.dumps(params)
28 headers = {
29 'Content-Length': len(body),
30 'Content-Type': 'application/json',
31 }
32 return body, headers
033
=== modified file 'src/apiclient/maas_client.py'
--- src/apiclient/maas_client.py 2012-10-05 07:38:54 +0000
+++ src/apiclient/maas_client.py 2012-10-08 15:04:22 +0000
@@ -19,6 +19,7 @@
19from urllib import urlencode19from urllib import urlencode
20import urllib220import urllib2
2121
22from apiclient.encode_json import encode_json_data
22from apiclient.multipart import encode_multipart_data23from apiclient.multipart import encode_multipart_data
23import oauth.oauth as oauth24import oauth.oauth as oauth
2425
@@ -138,7 +139,7 @@
138 self.auth.sign_request(url, headers)139 self.auth.sign_request(url, headers)
139 return url, headers140 return url, headers
140141
141 def _formulate_change(self, path, params):142 def _formulate_change(self, path, params, as_json=False):
142 """Return URL, headers, and body for a non-GET request.143 """Return URL, headers, and body for a non-GET request.
143144
144 This is similar to _formulate_get, except parameters are encoded as145 This is similar to _formulate_get, except parameters are encoded as
@@ -146,6 +147,9 @@
146147
147 :param path: Path to the object to issue a GET on.148 :param path: Path to the object to issue a GET on.
148 :param params: A dict of parameter values.149 :param params: A dict of parameter values.
150 :param as_json: Encode params as application/json instead of
151 application/x-www-form-urlencoded. Only use this if you know the
152 API already supports JSON requests.
149 :return: A tuple: URL, headers, and body for the request.153 :return: A tuple: URL, headers, and body for the request.
150 """154 """
151 url = self._make_url(path)155 url = self._make_url(path)
@@ -153,7 +157,10 @@
153 params = dict(params)157 params = dict(params)
154 op = params.pop('op')158 op = params.pop('op')
155 url += '?' + urlencode({'op': op})159 url += '?' + urlencode({'op': op})
156 body, headers = encode_multipart_data(params, {})160 if as_json:
161 body, headers = encode_json_data(params)
162 else:
163 body, headers = encode_multipart_data(params, {})
157 self.auth.sign_request(url, headers)164 self.auth.sign_request(url, headers)
158 return url, headers, body165 return url, headers, body
159166
@@ -170,13 +177,16 @@
170 return self.dispatcher.dispatch_query(177 return self.dispatcher.dispatch_query(
171 url, method="GET", headers=headers)178 url, method="GET", headers=headers)
172179
173 def post(self, path, op, **kwargs):180 def post(self, path, op, as_json=False, **kwargs):
174 """Dispatch POST method `op` on `path`, with the given parameters.181 """Dispatch POST method `op` on `path`, with the given parameters.
175182
183 :param as_json: Instead of POSTing the content as multipart
184 x-www-form-urlencoded post it as application/json
176 :return: The result of the dispatch_query call on the dispatcher.185 :return: The result of the dispatch_query call on the dispatcher.
177 """186 """
178 kwargs['op'] = op187 kwargs['op'] = op
179 url, headers, body = self._formulate_change(path, kwargs)188 url, headers, body = self._formulate_change(
189 path, kwargs, as_json=as_json)
180 return self.dispatcher.dispatch_query(190 return self.dispatcher.dispatch_query(
181 url, method="POST", headers=headers, data=body)191 url, method="POST", headers=headers, data=body)
182192
183193
=== modified file 'src/apiclient/testing/django.py'
--- src/apiclient/testing/django.py 2012-09-22 19:35:59 +0000
+++ src/apiclient/testing/django.py 2012-10-08 15:04:22 +0000
@@ -14,8 +14,17 @@
1414
15from io import BytesIO15from io import BytesIO
1616
17from django.core.handlers.wsgi import WSGIRequest
17from django.core.files.uploadhandler import MemoryFileUploadHandler18from django.core.files.uploadhandler import MemoryFileUploadHandler
18from django.http.multipartparser import MultiPartParser19from django.http.multipartparser import MultiPartParser
20from piston import emitters
21from piston.utils import translate_mime
22from maasserver.utils import ignore_unused
23
24
25# Importing emitters has a side effect of registering mime type handlers with
26# utils.translate_mime. So we must import it, even though we don't use it.
27ignore_unused(emitters)
1928
2029
21def parse_headers_and_body_with_django(headers, body):30def parse_headers_and_body_with_django(headers, body):
@@ -46,3 +55,20 @@
46 META=meta, input_data=BytesIO(body),55 META=meta, input_data=BytesIO(body),
47 upload_handlers=[handler])56 upload_handlers=[handler])
48 return parser.parse()57 return parser.parse()
58
59
60def parse_headers_and_body_with_mimer(headers, body):
61 """Use piston's Mimer functionality to handle the content.
62
63 :return: The value of 'request.data' after using Piston's translate_mime on
64 the input.
65 """
66 environ = {'wsgi.input': BytesIO(body)}
67 for name, value in headers.items():
68 environ[name.upper().replace('-', '_')] = value
69 environ['REQUEST_METHOD'] = 'POST'
70 environ['SCRIPT_NAME'] = ''
71 environ['PATH_INFO'] = ''
72 request = WSGIRequest(environ)
73 translate_mime(request)
74 return request.data
4975
=== added file 'src/apiclient/tests/test_encode_json.py'
--- src/apiclient/tests/test_encode_json.py 1970-01-01 00:00:00 +0000
+++ src/apiclient/tests/test_encode_json.py 2012-10-08 15:04:22 +0000
@@ -0,0 +1,36 @@
1# Copyright 2012 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Test encoding requests as JSON."""
5
6from __future__ import (
7 absolute_import,
8 print_function,
9 unicode_literals,
10 )
11
12__metaclass__ = type
13__all__ = []
14
15
16from apiclient.encode_json import encode_json_data
17from maastesting.testcase import TestCase
18
19
20class TestEncodeJSONData(TestCase):
21
22 def assertEncodeJSONData(self, expected_body, expected_headers, params):
23 self.assertEqual(
24 (expected_body, expected_headers),
25 encode_json_data(params))
26
27 def test_encode_empty_dict(self):
28 self.assertEncodeJSONData(
29 '{}', {'Content-Length': 2, 'Content-Type': 'application/json'},
30 {})
31
32 def test_encode_dict(self):
33 self.assertEncodeJSONData(
34 '{"alt": [1, 2, 3, 4], "param": "value"}',
35 {'Content-Length': 39, 'Content-Type': 'application/json'},
36 {'param': 'value', 'alt': [1, 2, 3, 4]})
037
=== modified file 'src/apiclient/tests/test_maas_client.py'
--- src/apiclient/tests/test_maas_client.py 2012-10-05 07:38:54 +0000
+++ src/apiclient/tests/test_maas_client.py 2012-10-08 15:04:22 +0000
@@ -12,6 +12,7 @@
12__metaclass__ = type12__metaclass__ = type
13__all__ = []13__all__ = []
1414
15import json
15from random import randint16from random import randint
16from urlparse import (17from urlparse import (
17 parse_qs,18 parse_qs,
@@ -24,7 +25,10 @@
24 MAASDispatcher,25 MAASDispatcher,
25 MAASOAuth,26 MAASOAuth,
26 )27 )
27from apiclient.testing.django import parse_headers_and_body_with_django28from apiclient.testing.django import (
29 parse_headers_and_body_with_django,
30 parse_headers_and_body_with_mimer,
31 )
28from maastesting.factory import factory32from maastesting.factory import factory
29from maastesting.testcase import TestCase33from maastesting.testcase import TestCase
3034
@@ -146,6 +150,16 @@
146 self.assertEqual(150 self.assertEqual(
147 {name: [value] for name, value in params.items()}, post)151 {name: [value] for name, value in params.items()}, post)
148152
153 def test_formulate_change_as_json(self):
154 params = {factory.getRandomString(): factory.getRandomString()}
155 url, headers, body = make_client()._formulate_change(
156 make_path(), params, as_json=True)
157 self.assertEqual('application/json', headers.get('Content-Type'))
158 self.assertEqual(len(body), headers.get('Content-Length'))
159 self.assertEqual(params, json.loads(body))
160 data = parse_headers_and_body_with_mimer(headers, body)
161 self.assertEqual(params, data)
162
149 def test_get_dispatches_to_resource(self):163 def test_get_dispatches_to_resource(self):
150 path = make_path()164 path = make_path()
151 client = make_client()165 client = make_client()
@@ -205,6 +219,21 @@
205 self.assertTrue(request["request_url"].endswith('?op=%s' % (method,)))219 self.assertTrue(request["request_url"].endswith('?op=%s' % (method,)))
206 self.assertEqual({"parameter": [param]}, post)220 self.assertEqual({"parameter": [param]}, post)
207221
222 def test_post_as_json(self):
223 param = factory.getRandomString()
224 method = factory.getRandomString()
225 list_param = [factory.getRandomString() for i in range(10)]
226 client = make_client()
227 client.post(make_path(), method, as_json=True,
228 param=param, list_param=list_param)
229 request = client.dispatcher.last_call
230 self.assertEqual('application/json',
231 request['headers'].get('Content-Type'))
232 content = parse_headers_and_body_with_mimer(
233 request['headers'], request['data'])
234 self.assertTrue(request["request_url"].endswith('?op=%s' % (method,)))
235 self.assertEqual({'param': param, 'list_param': list_param}, content)
236
208 def test_put_dispatches_to_resource(self):237 def test_put_dispatches_to_resource(self):
209 path = make_path()238 path = make_path()
210 client = make_client()239 client = make_client()