Merge lp:~elachuni/piston-mini-client/dual-scheme-support into lp:piston-mini-client

Proposed by Anthony Lenton
Status: Merged
Approved by: Anthony Lenton
Approved revision: 35
Merged at revision: 25
Proposed branch: lp:~elachuni/piston-mini-client/dual-scheme-support
Merge into: lp:piston-mini-client
Diff against target: 424 lines (+178/-23)
8 files modified
doc/conf.py (+2/-0)
doc/index.rst (+1/-0)
doc/quickstart.rst (+35/-0)
doc/reference.rst (+33/-0)
piston_mini_client/__init__.py (+33/-12)
piston_mini_client/failhandlers.py (+24/-1)
piston_mini_client/serializers.py (+6/-0)
piston_mini_client/test/test_resource.py (+44/-10)
To merge this branch: bzr merge lp:~elachuni/piston-mini-client/dual-scheme-support
Reviewer Review Type Date Requested Status
Danny Tamez (community) Approve
software-store-developers Pending
Review via email: mp+46914@code.launchpad.net

Description of the change

Overview
========
Provide support for talking to dual http/https apis.

Details
=======
Base work needed to allow certain methods in our api to use http requests, while others need to use https. Detailed docs in the diff itself, and in the related bugs.

To post a comment you must log in.
Revision history for this message
Danny Tamez (zematynnad) wrote :

Approved. Nice docs!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'doc/conf.py'
2--- doc/conf.py 2011-01-12 17:21:36 +0000
3+++ doc/conf.py 2011-01-20 14:24:08 +0000
4@@ -18,6 +18,8 @@
5 # documentation root, use os.path.abspath to make it absolute, like shown here.
6 #sys.path.insert(0, os.path.abspath('.'))
7
8+sys.path.insert(0, os.path.abspath('..'))
9+
10 # -- General configuration -----------------------------------------------------
11
12 # If your documentation needs a minimal Sphinx version, state it here.
13
14=== modified file 'doc/index.rst'
15--- doc/index.rst 2010-12-09 21:02:11 +0000
16+++ doc/index.rst 2011-01-20 14:24:08 +0000
17@@ -12,6 +12,7 @@
18 :maxdepth: 2
19
20 quickstart
21+ reference
22
23 Overview
24 ========
25
26=== modified file 'doc/quickstart.rst'
27--- doc/quickstart.rst 2011-01-12 15:37:40 +0000
28+++ doc/quickstart.rst 2011-01-20 14:24:08 +0000
29@@ -186,3 +186,38 @@
30 * ``MultiExceptionFailHandler``: Raises a different exception according to
31 what went wrong.
32
33+===============================
34+Talking to dual http/https apis
35+===============================
36+
37+Often your API provides a set of public calls, and some other calls that
38+are authenticated.
39+
40+Public calls sometimes are heavily used, so we'd like to
41+serve them over http. They're public anyway.
42+
43+Authenticated calls involve some sensitive information passing with the user's
44+credentials, so we like serving those over https.
45+
46+Once you've got all this set up on the server, you can ask piston_mini_client
47+to make each call using the appropriate scheme by using the ``scheme``
48+optional argument when you call ``_get`` or ``_post``::
49+
50+ class DualAPI(PistonAPI):
51+ default_service_root = 'http://myhostname.com/api/1.0'
52+
53+ def public_method(self):
54+ return self._get('public_method/', scheme='http')
55+
56+ def private_method(self):
57+ return self._post('private_method/', scheme='https')
58+
59+ def either(self):
60+ return self._get('either/')
61+
62+In this case, no matter what scheme the service root uses, calls to
63+``public_method()`` will result in
64+an http request, and calls to ``private_method()`` will result in an https
65+request. Calls to ``either()`` will leave the scheme alone, so it will
66+follow the scheme used to instantiate the api, or fall back to
67+``default_service_root``'s scheme.
68\ No newline at end of file
69
70=== added file 'doc/reference.rst'
71--- doc/reference.rst 1970-01-01 00:00:00 +0000
72+++ doc/reference.rst 2011-01-20 14:24:08 +0000
73@@ -0,0 +1,33 @@
74+Reference
75+=========
76+
77+.. automodule:: piston_mini_client
78+ :members:
79+
80+==========
81+Validators
82+==========
83+
84+.. automodule:: piston_mini_client.validators
85+ :members:
86+
87+=============
88+Fail handlers
89+=============
90+
91+.. automodule:: piston_mini_client.failhandlers
92+ :members:
93+
94+===========
95+Serializers
96+===========
97+
98+.. automodule:: piston_mini_client.serializers
99+ :members:
100+
101+==============
102+Authentication
103+==============
104+
105+.. automodule:: piston_mini_client.auth
106+ :members:
107
108=== modified file 'piston_mini_client/__init__.py'
109--- piston_mini_client/__init__.py 2011-01-12 17:21:36 +0000
110+++ piston_mini_client/__init__.py 2011-01-20 14:24:08 +0000
111@@ -6,6 +6,7 @@
112 import simplejson
113 import urllib
114 from functools import wraps
115+from urlparse import urlparse, urlunparse
116
117 from failhandlers import ExceptionFailHandler, APIError
118
119@@ -61,8 +62,7 @@
120
121
122 class PistonResponseObject(object):
123- """Base class for objects that are returned from api calls.
124- """
125+ """Base class for objects that are returned from api calls."""
126 @classmethod
127 def from_response(cls, body, none_allowed=False):
128 data = simplejson.loads(body)
129@@ -79,8 +79,7 @@
130 return obj
131
132 class PistonSerializable(object):
133- """Base class for objects that want to be used as api call arguments.
134- """
135+ """Base class for objects that want to be used as api call arguments."""
136 _atts = ()
137
138 def __init__(self, **kwargs):
139@@ -119,11 +118,17 @@
140 def __init__(self, service_root=None, cachedir=None, auth=None):
141 if service_root is None:
142 service_root = self.default_service_root
143+ if not service_root:
144+ raise ValueError("No service_root provided, and no default found")
145+ parsed_service_root = urlparse(service_root)
146+ if parsed_service_root.scheme not in ['http', 'https']:
147+ raise ValueError("service_root's scheme must be http or https")
148 self._service_root = service_root
149+ self._parsed_service_root = list(parsed_service_root)
150 self._cachedir = cachedir
151 self._auth = auth
152
153- def _get(self, path, args=None):
154+ def _get(self, path, args=None, scheme=None):
155 """Perform an HTTP GET request.
156
157 The provided 'path' is appended to this resource's '_service_root'
158@@ -131,6 +136,10 @@
159
160 If provided, 'args' should be a dict specifying additional GET
161 arguments that will be encoded on to the end of the url.
162+
163+ 'scheme' must be one of 'http' or 'https', and will determine the
164+ scheme used for this particular request. If not provided the
165+ service_root's scheme will be used.
166 """
167 if args is not None:
168 if '?' in path:
169@@ -138,9 +147,9 @@
170 else:
171 path += '?'
172 path += urllib.urlencode(args)
173- return self._request(path, method='GET')
174+ return self._request(path, method='GET', scheme=scheme)
175
176- def _post(self, path, data=None, content_type=None):
177+ def _post(self, path, data=None, content_type=None, scheme=None):
178 """Perform an HTTP POST request.
179
180 The provided path is appended to this api's '_service_root' attribute
181@@ -153,6 +162,10 @@
182 serializer from serializers.
183
184 If content_type is None, self.default_content_type will be used.
185+
186+ 'scheme' must be one of 'http' or 'https', and will determine the
187+ scheme used for this particular request. If not provided the
188+ service_root's scheme will be used.
189 """
190 body = data
191 if content_type is None:
192@@ -163,9 +176,10 @@
193 serializer = get_serializer(content_type)(data)
194 body = serializer.serialize()
195 headers = {'Content-type': content_type}
196- return self._request(path, method='POST', body=body, headers=headers)
197+ return self._request(path, method='POST', body=body,
198+ headers=headers, scheme=scheme)
199
200- def _request(self, path, method, body='', headers=None):
201+ def _request(self, path, method, body='', headers=None, scheme=None):
202 """Perform an HTTP request.
203
204 You probably want to call one of the _get, _post methods instead.
205@@ -173,7 +187,9 @@
206 http = httplib2.Http(cache=self._cachedir)
207 if headers is None:
208 headers = {}
209- url = self._path2url(path)
210+ if scheme not in [None, 'http', 'https']:
211+ raise ValueError('Invalid scheme %s' % scheme)
212+ url = self._path2url(path, scheme=scheme)
213 if self._auth:
214 self._auth.sign_request(url, method, body, headers)
215 try:
216@@ -189,6 +205,11 @@
217 body = handler.handle(response, body)
218 return body
219
220- def _path2url(self, path):
221- return self._service_root.rstrip('/') + '/' + path.lstrip('/')
222+ def _path2url(self, path, scheme=None):
223+ if scheme is None:
224+ service_root = self._service_root
225+ else:
226+ parts = [scheme] + self._parsed_service_root[1:]
227+ service_root = urlunparse(parts)
228+ return service_root.strip('/') + '/' + path.lstrip('/')
229
230
231=== modified file 'piston_mini_client/failhandlers.py'
232--- piston_mini_client/failhandlers.py 2011-01-18 14:44:10 +0000
233+++ piston_mini_client/failhandlers.py 2011-01-20 14:24:08 +0000
234@@ -8,6 +8,15 @@
235 possibly decorators) to know what to do with these returned objects.
236 """
237
238+__all__ = [
239+ 'APIError',
240+ 'BaseFailHandler',
241+ 'ExceptionFailHandler',
242+ 'DictFailHandler',
243+ 'NoneFailHandler',
244+ 'MultiExceptionFailHandler',
245+]
246+
247 class APIError(Exception):
248 def __init__(self, msg, body=None):
249 self.msg = msg
250@@ -53,7 +62,7 @@
251 went wrong.
252 """
253 def handle(self, response, body):
254- """Raise APIError if a strange status code is found"""
255+ """Return None if a strange status code is found"""
256 if self.was_error(response):
257 return None
258 return body
259@@ -63,6 +72,12 @@
260 """A fail handler that returns error information in a dict"""
261
262 def handle(self, response, body):
263+ """Return a dict if a strange status code is found.
264+
265+ The returned dict will have two keys:
266+ * 'response': the httplib2 response header dict
267+ * 'body': the response body, as a string
268+ """
269 if self.was_error(response):
270 return {'response': response, 'body': body}
271 return body
272@@ -94,6 +109,14 @@
273 }
274
275 def handle(self, response, body):
276+ """Return an exception according to what went wrong.
277+
278+ Status codes currently returning their own exception class are:
279+ * 400: BadRequestError,
280+ * 401: UnauthorizedError,
281+ * 404: NotFoundError, and
282+ * 500: InternalServerErrorError
283+ """
284 if self.was_error(response):
285 status = response.get('status')
286 exception_class = self.exceptions.get(status, APIError)
287
288=== modified file 'piston_mini_client/serializers.py'
289--- piston_mini_client/serializers.py 2011-01-12 17:21:36 +0000
290+++ piston_mini_client/serializers.py 2011-01-20 14:24:08 +0000
291@@ -2,6 +2,12 @@
292 # Copyright 2010-2011 Canonical Ltd. This software is licensed under the
293 # GNU Lesser General Public License version 3 (see the file LICENSE).
294
295+__all__ = [
296+ 'BaseSerializer',
297+ 'JSONSerializer',
298+ 'FormSerializer',
299+]
300+
301 import simplejson
302 import urllib
303
304
305=== modified file 'piston_mini_client/test/test_resource.py'
306--- piston_mini_client/test/test_resource.py 2011-01-18 14:44:10 +0000
307+++ piston_mini_client/test/test_resource.py 2011-01-20 14:24:08 +0000
308@@ -13,7 +13,6 @@
309
310
311 class PistonAPITestCase(TestCase):
312- # XXX achuni 2010-11-30: We need to test different return codes
313 class CoffeeAPI(PistonAPI):
314 default_service_root = 'http://localhost:12345'
315 def brew(self):
316@@ -26,6 +25,7 @@
317 'auth': environ.get('HTTP_AUTHORIZATION'),
318 'content_type': environ.get('CONTENT_TYPE'),
319 'query': environ.get('QUERY_STRING'),
320+ 'scheme': environ.get('wsgi.url_scheme'),
321 }
322 return 'hello world'
323
324@@ -97,17 +97,51 @@
325 self.assertEqual('zot=ping&foo=bar', self.called['query'])
326
327 def test_path2url_with_no_ending_slash(self):
328- resource = PistonAPI()
329- resource._service_root = 'http://example.com/api'
330+ resource = PistonAPI('http://example.com/api')
331 expected = 'http://example.com/api/frobble'
332 self.assertEqual(expected, resource._path2url('frobble'))
333
334 def test_path2url_with_ending_slash(self):
335- resource = PistonAPI()
336- resource._service_root = 'http://example.com/api/'
337+ resource = PistonAPI('http://example.com/api/')
338 expected = 'http://example.com/api/frobble'
339 self.assertEqual(expected, resource._path2url('frobble'))
340
341+ def test_instantiation_fails_with_no_service_root(self):
342+ try:
343+ self.CoffeeAPI.default_service_root = None
344+ self.assertRaises(ValueError, self.CoffeeAPI)
345+ finally:
346+ self.CoffeeAPI.default_service_root = 'http://localhost:12345'
347+
348+
349+ def test_instantiation_fails_with_invalid_scheme(self):
350+ self.assertRaises(ValueError, self.CoffeeAPI, 'ftp://foobar.baz')
351+
352+ @patch('httplib2.Http.request')
353+ def test_request_scheme_switch_to_https(self, mock_request):
354+ mock_request.return_value = ({'status': '200'}, '""')
355+ api = self.CoffeeAPI()
356+ api._request('/foo', 'GET', scheme='https')
357+ mock_request.assert_called_with('https://localhost:12345/foo',
358+ body='', headers={}, method='GET')
359+
360+ @patch('httplib2.Http.request')
361+ def test_get_scheme_switch_to_https(self, mock_request):
362+ mock_request.return_value = ({'status': '200'}, '""')
363+ api = self.CoffeeAPI()
364+ api._get('/foo', scheme='https')
365+ mock_request.assert_called_with('https://localhost:12345/foo',
366+ body='', headers={}, method='GET')
367+
368+ @patch('httplib2.Http.request')
369+ def test_post_scheme_switch_to_https(self, mock_request):
370+ mock_request.return_value = ({'status': '200'}, '""')
371+ api = self.CoffeeAPI()
372+ api._post('/foo', scheme='https')
373+ mock_request.assert_called_with('https://localhost:12345/foo',
374+ body='null', headers={'Content-type': 'application/json'},
375+ method='POST')
376+
377
378 class PistonResponseObjectTestCase(TestCase):
379 def test_from_response(self):
380@@ -122,7 +156,7 @@
381 class ReturnsJSONTestCase(TestCase):
382 def test_returns_json(self):
383 class MyAPI(PistonAPI):
384- default_service_root = 'foo'
385+ default_service_root = 'http://foo'
386 @returns_json
387 def func(self):
388 return '{"foo": "bar", "baz": 42}'
389@@ -134,7 +168,7 @@
390 class ReturnsTestCase(TestCase):
391 def test_returns(self):
392 class MyAPI(PistonAPI):
393- default_service_root = 'foo'
394+ default_service_root = 'http://foo'
395 @returns(PistonResponseObject)
396 def func(self):
397 return '{"foo": "bar", "baz": 42}'
398@@ -144,7 +178,7 @@
399
400 def test_returns_none_allowed(self):
401 class MyAPI(PistonAPI):
402- default_service_root = 'foo'
403+ default_service_root = 'http://foo'
404 @returns(PistonResponseObject, none_allowed=True)
405 def func(self):
406 return 'null'
407@@ -154,7 +188,7 @@
408
409 def test_returns_none_allowed_normal_response(self):
410 class MyAPI(PistonAPI):
411- default_service_root = 'foo'
412+ default_service_root = 'http://foo'
413 @returns(PistonResponseObject, none_allowed=True)
414 def func(self):
415 return '{"foo": "bar", "baz": 42}'
416@@ -166,7 +200,7 @@
417 class ReturnsListOfTestCase(TestCase):
418 def test_returns(self):
419 class MyAPI(PistonAPI):
420- default_service_root = 'foo'
421+ default_service_root = 'http://foo'
422 @returns_list_of(PistonResponseObject)
423 def func(self):
424 return '[{"foo": "bar"}, {"baz": 42}]'

Subscribers

People subscribed via source and target branches