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
=== modified file 'doc/conf.py'
--- doc/conf.py 2011-01-12 17:21:36 +0000
+++ doc/conf.py 2011-01-20 14:24:08 +0000
@@ -18,6 +18,8 @@
18# documentation root, use os.path.abspath to make it absolute, like shown here.18# documentation root, use os.path.abspath to make it absolute, like shown here.
19#sys.path.insert(0, os.path.abspath('.'))19#sys.path.insert(0, os.path.abspath('.'))
2020
21sys.path.insert(0, os.path.abspath('..'))
22
21# -- General configuration -----------------------------------------------------23# -- General configuration -----------------------------------------------------
2224
23# If your documentation needs a minimal Sphinx version, state it here.25# If your documentation needs a minimal Sphinx version, state it here.
2426
=== modified file 'doc/index.rst'
--- doc/index.rst 2010-12-09 21:02:11 +0000
+++ doc/index.rst 2011-01-20 14:24:08 +0000
@@ -12,6 +12,7 @@
12 :maxdepth: 212 :maxdepth: 2
1313
14 quickstart14 quickstart
15 reference
1516
16Overview17Overview
17========18========
1819
=== modified file 'doc/quickstart.rst'
--- doc/quickstart.rst 2011-01-12 15:37:40 +0000
+++ doc/quickstart.rst 2011-01-20 14:24:08 +0000
@@ -186,3 +186,38 @@
186 * ``MultiExceptionFailHandler``: Raises a different exception according to186 * ``MultiExceptionFailHandler``: Raises a different exception according to
187 what went wrong.187 what went wrong.
188188
189===============================
190Talking to dual http/https apis
191===============================
192
193Often your API provides a set of public calls, and some other calls that
194are authenticated.
195
196Public calls sometimes are heavily used, so we'd like to
197serve them over http. They're public anyway.
198
199Authenticated calls involve some sensitive information passing with the user's
200credentials, so we like serving those over https.
201
202Once you've got all this set up on the server, you can ask piston_mini_client
203to make each call using the appropriate scheme by using the ``scheme``
204optional argument when you call ``_get`` or ``_post``::
205
206 class DualAPI(PistonAPI):
207 default_service_root = 'http://myhostname.com/api/1.0'
208
209 def public_method(self):
210 return self._get('public_method/', scheme='http')
211
212 def private_method(self):
213 return self._post('private_method/', scheme='https')
214
215 def either(self):
216 return self._get('either/')
217
218In this case, no matter what scheme the service root uses, calls to
219``public_method()`` will result in
220an http request, and calls to ``private_method()`` will result in an https
221request. Calls to ``either()`` will leave the scheme alone, so it will
222follow the scheme used to instantiate the api, or fall back to
223``default_service_root``'s scheme.
189\ No newline at end of file224\ No newline at end of file
190225
=== added file 'doc/reference.rst'
--- doc/reference.rst 1970-01-01 00:00:00 +0000
+++ doc/reference.rst 2011-01-20 14:24:08 +0000
@@ -0,0 +1,33 @@
1Reference
2=========
3
4.. automodule:: piston_mini_client
5 :members:
6
7==========
8Validators
9==========
10
11.. automodule:: piston_mini_client.validators
12 :members:
13
14=============
15Fail handlers
16=============
17
18.. automodule:: piston_mini_client.failhandlers
19 :members:
20
21===========
22Serializers
23===========
24
25.. automodule:: piston_mini_client.serializers
26 :members:
27
28==============
29Authentication
30==============
31
32.. automodule:: piston_mini_client.auth
33 :members:
034
=== modified file 'piston_mini_client/__init__.py'
--- piston_mini_client/__init__.py 2011-01-12 17:21:36 +0000
+++ piston_mini_client/__init__.py 2011-01-20 14:24:08 +0000
@@ -6,6 +6,7 @@
6import simplejson6import simplejson
7import urllib7import urllib
8from functools import wraps8from functools import wraps
9from urlparse import urlparse, urlunparse
910
10from failhandlers import ExceptionFailHandler, APIError11from failhandlers import ExceptionFailHandler, APIError
1112
@@ -61,8 +62,7 @@
6162
6263
63class PistonResponseObject(object):64class PistonResponseObject(object):
64 """Base class for objects that are returned from api calls.65 """Base class for objects that are returned from api calls."""
65 """
66 @classmethod66 @classmethod
67 def from_response(cls, body, none_allowed=False):67 def from_response(cls, body, none_allowed=False):
68 data = simplejson.loads(body)68 data = simplejson.loads(body)
@@ -79,8 +79,7 @@
79 return obj79 return obj
8080
81class PistonSerializable(object):81class PistonSerializable(object):
82 """Base class for objects that want to be used as api call arguments.82 """Base class for objects that want to be used as api call arguments."""
83 """
84 _atts = ()83 _atts = ()
8584
86 def __init__(self, **kwargs):85 def __init__(self, **kwargs):
@@ -119,11 +118,17 @@
119 def __init__(self, service_root=None, cachedir=None, auth=None):118 def __init__(self, service_root=None, cachedir=None, auth=None):
120 if service_root is None:119 if service_root is None:
121 service_root = self.default_service_root120 service_root = self.default_service_root
121 if not service_root:
122 raise ValueError("No service_root provided, and no default found")
123 parsed_service_root = urlparse(service_root)
124 if parsed_service_root.scheme not in ['http', 'https']:
125 raise ValueError("service_root's scheme must be http or https")
122 self._service_root = service_root126 self._service_root = service_root
127 self._parsed_service_root = list(parsed_service_root)
123 self._cachedir = cachedir128 self._cachedir = cachedir
124 self._auth = auth129 self._auth = auth
125130
126 def _get(self, path, args=None):131 def _get(self, path, args=None, scheme=None):
127 """Perform an HTTP GET request.132 """Perform an HTTP GET request.
128133
129 The provided 'path' is appended to this resource's '_service_root'134 The provided 'path' is appended to this resource's '_service_root'
@@ -131,6 +136,10 @@
131136
132 If provided, 'args' should be a dict specifying additional GET137 If provided, 'args' should be a dict specifying additional GET
133 arguments that will be encoded on to the end of the url.138 arguments that will be encoded on to the end of the url.
139
140 'scheme' must be one of 'http' or 'https', and will determine the
141 scheme used for this particular request. If not provided the
142 service_root's scheme will be used.
134 """143 """
135 if args is not None:144 if args is not None:
136 if '?' in path:145 if '?' in path:
@@ -138,9 +147,9 @@
138 else:147 else:
139 path += '?'148 path += '?'
140 path += urllib.urlencode(args)149 path += urllib.urlencode(args)
141 return self._request(path, method='GET')150 return self._request(path, method='GET', scheme=scheme)
142151
143 def _post(self, path, data=None, content_type=None):152 def _post(self, path, data=None, content_type=None, scheme=None):
144 """Perform an HTTP POST request.153 """Perform an HTTP POST request.
145154
146 The provided path is appended to this api's '_service_root' attribute155 The provided path is appended to this api's '_service_root' attribute
@@ -153,6 +162,10 @@
153 serializer from serializers.162 serializer from serializers.
154163
155 If content_type is None, self.default_content_type will be used.164 If content_type is None, self.default_content_type will be used.
165
166 'scheme' must be one of 'http' or 'https', and will determine the
167 scheme used for this particular request. If not provided the
168 service_root's scheme will be used.
156 """169 """
157 body = data170 body = data
158 if content_type is None:171 if content_type is None:
@@ -163,9 +176,10 @@
163 serializer = get_serializer(content_type)(data)176 serializer = get_serializer(content_type)(data)
164 body = serializer.serialize()177 body = serializer.serialize()
165 headers = {'Content-type': content_type}178 headers = {'Content-type': content_type}
166 return self._request(path, method='POST', body=body, headers=headers)179 return self._request(path, method='POST', body=body,
180 headers=headers, scheme=scheme)
167181
168 def _request(self, path, method, body='', headers=None):182 def _request(self, path, method, body='', headers=None, scheme=None):
169 """Perform an HTTP request.183 """Perform an HTTP request.
170184
171 You probably want to call one of the _get, _post methods instead.185 You probably want to call one of the _get, _post methods instead.
@@ -173,7 +187,9 @@
173 http = httplib2.Http(cache=self._cachedir)187 http = httplib2.Http(cache=self._cachedir)
174 if headers is None:188 if headers is None:
175 headers = {}189 headers = {}
176 url = self._path2url(path)190 if scheme not in [None, 'http', 'https']:
191 raise ValueError('Invalid scheme %s' % scheme)
192 url = self._path2url(path, scheme=scheme)
177 if self._auth:193 if self._auth:
178 self._auth.sign_request(url, method, body, headers)194 self._auth.sign_request(url, method, body, headers)
179 try:195 try:
@@ -189,6 +205,11 @@
189 body = handler.handle(response, body)205 body = handler.handle(response, body)
190 return body206 return body
191207
192 def _path2url(self, path):208 def _path2url(self, path, scheme=None):
193 return self._service_root.rstrip('/') + '/' + path.lstrip('/')209 if scheme is None:
210 service_root = self._service_root
211 else:
212 parts = [scheme] + self._parsed_service_root[1:]
213 service_root = urlunparse(parts)
214 return service_root.strip('/') + '/' + path.lstrip('/')
194215
195216
=== modified file 'piston_mini_client/failhandlers.py'
--- piston_mini_client/failhandlers.py 2011-01-18 14:44:10 +0000
+++ piston_mini_client/failhandlers.py 2011-01-20 14:24:08 +0000
@@ -8,6 +8,15 @@
8possibly decorators) to know what to do with these returned objects.8possibly decorators) to know what to do with these returned objects.
9"""9"""
1010
11__all__ = [
12 'APIError',
13 'BaseFailHandler',
14 'ExceptionFailHandler',
15 'DictFailHandler',
16 'NoneFailHandler',
17 'MultiExceptionFailHandler',
18]
19
11class APIError(Exception):20class APIError(Exception):
12 def __init__(self, msg, body=None):21 def __init__(self, msg, body=None):
13 self.msg = msg22 self.msg = msg
@@ -53,7 +62,7 @@
53 went wrong.62 went wrong.
54 """63 """
55 def handle(self, response, body):64 def handle(self, response, body):
56 """Raise APIError if a strange status code is found"""65 """Return None if a strange status code is found"""
57 if self.was_error(response):66 if self.was_error(response):
58 return None67 return None
59 return body68 return body
@@ -63,6 +72,12 @@
63 """A fail handler that returns error information in a dict"""72 """A fail handler that returns error information in a dict"""
6473
65 def handle(self, response, body):74 def handle(self, response, body):
75 """Return a dict if a strange status code is found.
76
77 The returned dict will have two keys:
78 * 'response': the httplib2 response header dict
79 * 'body': the response body, as a string
80 """
66 if self.was_error(response):81 if self.was_error(response):
67 return {'response': response, 'body': body}82 return {'response': response, 'body': body}
68 return body83 return body
@@ -94,6 +109,14 @@
94 }109 }
95110
96 def handle(self, response, body):111 def handle(self, response, body):
112 """Return an exception according to what went wrong.
113
114 Status codes currently returning their own exception class are:
115 * 400: BadRequestError,
116 * 401: UnauthorizedError,
117 * 404: NotFoundError, and
118 * 500: InternalServerErrorError
119 """
97 if self.was_error(response):120 if self.was_error(response):
98 status = response.get('status')121 status = response.get('status')
99 exception_class = self.exceptions.get(status, APIError)122 exception_class = self.exceptions.get(status, APIError)
100123
=== modified file 'piston_mini_client/serializers.py'
--- piston_mini_client/serializers.py 2011-01-12 17:21:36 +0000
+++ piston_mini_client/serializers.py 2011-01-20 14:24:08 +0000
@@ -2,6 +2,12 @@
2# Copyright 2010-2011 Canonical Ltd. This software is licensed under the2# Copyright 2010-2011 Canonical Ltd. This software is licensed under the
3# GNU Lesser General Public License version 3 (see the file LICENSE).3# GNU Lesser General Public License version 3 (see the file LICENSE).
44
5__all__ = [
6 'BaseSerializer',
7 'JSONSerializer',
8 'FormSerializer',
9]
10
5import simplejson11import simplejson
6import urllib12import urllib
713
814
=== modified file 'piston_mini_client/test/test_resource.py'
--- piston_mini_client/test/test_resource.py 2011-01-18 14:44:10 +0000
+++ piston_mini_client/test/test_resource.py 2011-01-20 14:24:08 +0000
@@ -13,7 +13,6 @@
1313
1414
15class PistonAPITestCase(TestCase):15class PistonAPITestCase(TestCase):
16 # XXX achuni 2010-11-30: We need to test different return codes
17 class CoffeeAPI(PistonAPI):16 class CoffeeAPI(PistonAPI):
18 default_service_root = 'http://localhost:12345'17 default_service_root = 'http://localhost:12345'
19 def brew(self):18 def brew(self):
@@ -26,6 +25,7 @@
26 'auth': environ.get('HTTP_AUTHORIZATION'),25 'auth': environ.get('HTTP_AUTHORIZATION'),
27 'content_type': environ.get('CONTENT_TYPE'),26 'content_type': environ.get('CONTENT_TYPE'),
28 'query': environ.get('QUERY_STRING'),27 'query': environ.get('QUERY_STRING'),
28 'scheme': environ.get('wsgi.url_scheme'),
29 }29 }
30 return 'hello world'30 return 'hello world'
3131
@@ -97,17 +97,51 @@
97 self.assertEqual('zot=ping&foo=bar', self.called['query'])97 self.assertEqual('zot=ping&foo=bar', self.called['query'])
9898
99 def test_path2url_with_no_ending_slash(self):99 def test_path2url_with_no_ending_slash(self):
100 resource = PistonAPI()100 resource = PistonAPI('http://example.com/api')
101 resource._service_root = 'http://example.com/api'
102 expected = 'http://example.com/api/frobble'101 expected = 'http://example.com/api/frobble'
103 self.assertEqual(expected, resource._path2url('frobble'))102 self.assertEqual(expected, resource._path2url('frobble'))
104103
105 def test_path2url_with_ending_slash(self):104 def test_path2url_with_ending_slash(self):
106 resource = PistonAPI()105 resource = PistonAPI('http://example.com/api/')
107 resource._service_root = 'http://example.com/api/'
108 expected = 'http://example.com/api/frobble'106 expected = 'http://example.com/api/frobble'
109 self.assertEqual(expected, resource._path2url('frobble'))107 self.assertEqual(expected, resource._path2url('frobble'))
110108
109 def test_instantiation_fails_with_no_service_root(self):
110 try:
111 self.CoffeeAPI.default_service_root = None
112 self.assertRaises(ValueError, self.CoffeeAPI)
113 finally:
114 self.CoffeeAPI.default_service_root = 'http://localhost:12345'
115
116
117 def test_instantiation_fails_with_invalid_scheme(self):
118 self.assertRaises(ValueError, self.CoffeeAPI, 'ftp://foobar.baz')
119
120 @patch('httplib2.Http.request')
121 def test_request_scheme_switch_to_https(self, mock_request):
122 mock_request.return_value = ({'status': '200'}, '""')
123 api = self.CoffeeAPI()
124 api._request('/foo', 'GET', scheme='https')
125 mock_request.assert_called_with('https://localhost:12345/foo',
126 body='', headers={}, method='GET')
127
128 @patch('httplib2.Http.request')
129 def test_get_scheme_switch_to_https(self, mock_request):
130 mock_request.return_value = ({'status': '200'}, '""')
131 api = self.CoffeeAPI()
132 api._get('/foo', scheme='https')
133 mock_request.assert_called_with('https://localhost:12345/foo',
134 body='', headers={}, method='GET')
135
136 @patch('httplib2.Http.request')
137 def test_post_scheme_switch_to_https(self, mock_request):
138 mock_request.return_value = ({'status': '200'}, '""')
139 api = self.CoffeeAPI()
140 api._post('/foo', scheme='https')
141 mock_request.assert_called_with('https://localhost:12345/foo',
142 body='null', headers={'Content-type': 'application/json'},
143 method='POST')
144
111145
112class PistonResponseObjectTestCase(TestCase):146class PistonResponseObjectTestCase(TestCase):
113 def test_from_response(self):147 def test_from_response(self):
@@ -122,7 +156,7 @@
122class ReturnsJSONTestCase(TestCase):156class ReturnsJSONTestCase(TestCase):
123 def test_returns_json(self):157 def test_returns_json(self):
124 class MyAPI(PistonAPI):158 class MyAPI(PistonAPI):
125 default_service_root = 'foo'159 default_service_root = 'http://foo'
126 @returns_json160 @returns_json
127 def func(self):161 def func(self):
128 return '{"foo": "bar", "baz": 42}'162 return '{"foo": "bar", "baz": 42}'
@@ -134,7 +168,7 @@
134class ReturnsTestCase(TestCase):168class ReturnsTestCase(TestCase):
135 def test_returns(self):169 def test_returns(self):
136 class MyAPI(PistonAPI):170 class MyAPI(PistonAPI):
137 default_service_root = 'foo'171 default_service_root = 'http://foo'
138 @returns(PistonResponseObject)172 @returns(PistonResponseObject)
139 def func(self):173 def func(self):
140 return '{"foo": "bar", "baz": 42}'174 return '{"foo": "bar", "baz": 42}'
@@ -144,7 +178,7 @@
144178
145 def test_returns_none_allowed(self):179 def test_returns_none_allowed(self):
146 class MyAPI(PistonAPI):180 class MyAPI(PistonAPI):
147 default_service_root = 'foo'181 default_service_root = 'http://foo'
148 @returns(PistonResponseObject, none_allowed=True)182 @returns(PistonResponseObject, none_allowed=True)
149 def func(self):183 def func(self):
150 return 'null'184 return 'null'
@@ -154,7 +188,7 @@
154188
155 def test_returns_none_allowed_normal_response(self):189 def test_returns_none_allowed_normal_response(self):
156 class MyAPI(PistonAPI):190 class MyAPI(PistonAPI):
157 default_service_root = 'foo'191 default_service_root = 'http://foo'
158 @returns(PistonResponseObject, none_allowed=True)192 @returns(PistonResponseObject, none_allowed=True)
159 def func(self):193 def func(self):
160 return '{"foo": "bar", "baz": 42}'194 return '{"foo": "bar", "baz": 42}'
@@ -166,7 +200,7 @@
166class ReturnsListOfTestCase(TestCase):200class ReturnsListOfTestCase(TestCase):
167 def test_returns(self):201 def test_returns(self):
168 class MyAPI(PistonAPI):202 class MyAPI(PistonAPI):
169 default_service_root = 'foo'203 default_service_root = 'http://foo'
170 @returns_list_of(PistonResponseObject)204 @returns_list_of(PistonResponseObject)
171 def func(self):205 def func(self):
172 return '[{"foo": "bar"}, {"baz": 42}]'206 return '[{"foo": "bar"}, {"baz": 42}]'

Subscribers

People subscribed via source and target branches