Merge lp:~johannes.erdfelt/nova/lp844905 into lp:~hudson-openstack/nova/trunk
- lp844905
- Merge into trunk
Status: | Rejected | ||||
---|---|---|---|---|---|
Rejected by: | Brian Waldon | ||||
Proposed branch: | lp:~johannes.erdfelt/nova/lp844905 | ||||
Merge into: | lp:~hudson-openstack/nova/trunk | ||||
Diff against target: |
827 lines (+522/-81) 7 files modified
etc/nova/api-paste.ini (+1/-1) nova/api/openstack/urlmap.py (+297/-0) nova/api/openstack/versions.py (+6/-27) nova/api/openstack/wsgi.py (+24/-28) nova/tests/api/openstack/fakes.py (+1/-1) nova/tests/api/openstack/test_urlmap.py (+111/-0) nova/tests/api/openstack/test_versions.py (+82/-24) |
||||
To merge this branch: | bzr merge lp:~johannes.erdfelt/nova/lp844905 | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Brian Waldon (community) | Approve | ||
Paul Voccio (community) | Abstain | ||
Review via email: mp+76439@code.launchpad.net |
Commit message
The 1.1 API specifies that the API version can be determined by URL path (eg /v1.1/tenant/
This branch adds support for looking at the Content-Type and Accept headers for the API version to use.
Description of the change
The 1.1 API specifies that the API version can be determined by URL path (eg /v1.1/tenant/
This branch adds support for looking at the Content-Type and Accept headers for the API version to use.
This merge proposal is more of a code review than a claim than the branch is ready. I'd like to hear opinions on the direction of the branch.
In particular:
* The approach I took using a custom URLMap class to integrate into paste deploy. It has the benefit of still allowing the paste config to enable/disable API versions. It subclasses a paste class, so it may end up being fragile in the long term.
* To support this correctly, the accepted content type needs to be determined much earlier than before (so we get the right version specified by the MIME type in the Accept header). Unfortunately, there are a couple of resources that define an additional supported content type of application/
* Much of the current code tests like to skip the paste deploy part of the WSGI stack. Also the direct API doesn't use paste deploy. As a result, I left in code to determine the accepted content type at a later point. I originally went down the path of changing the rest of the code, but that resulted in a set of huge changes, but also some corner cases where only part of the WSGI stack is intentionally tested.
- 1588. By Johannes Erdfelt
-
Remove hardcoded content type now that the code handles direct API again
- 1589. By Johannes Erdfelt
-
Remove FIXME now that it has a workaround
- 1590. By Johannes Erdfelt
-
Cleanup docstrings
Remove is_filename option, it's not necessary for us and is a hack anyway
Sandy Walsh (sandy-walsh) wrote : | # |
Johannes Erdfelt (johannes.erdfelt) wrote : | # |
I'm not sure I understand what needs to be changed.
The unit tests check the two different methods to determine the best content type and the three different methods to determine the API version.
Each unit test checks a single piece of functionality, so I'm not sure I understand what you mean by "single-method unit tests".
Are you saying some code isn't being tested? The existing code being tested isn't tested well enough?
- 1591. By Johannes Erdfelt
-
Add docstrings to tests to better document the intent
Paul Voccio (pvo) wrote : | # |
Wanted to echo removing comments and some of the formatting the others mentioned here.
I got some 3 failures with the S3APITestCase. Not sure if this is related or not yet.
Please also add yourself to the Authors file. This failed on the unit tests for me.
=======
FAIL: test_authors_
-------
Traceback (most recent call last):
File "/root/
'%r not listed in Authors' % missing)
AssertionError: set([u'<email address hidden>']) not listed in Authors
Paul Voccio (pvo) wrote : | # |
meh, too many windows open.
Brian Waldon (bcwaldon) wrote : | # |
I'm very curious why nova/api/
Since we have to do content-type checking up front now, can we guarantee it *always* gets done up front? It seems like best_match_
Johannes Erdfelt (johannes.erdfelt) wrote : | # |
paste deploy determines which versions of the API are available by using the configuration in etc/nova/
My original stab at this task modified nova.api.
The reason for best_match_
I can't say I'm pleased with having two different methods of determining content type either. It might be possible to fix the direct API to use a different request object or maybe even use paste deploy. It might also be possible to mock up a different request object for the tests. I think there might be other problems too, I ran into a lot when I tried developing a patch that left best_match_
It's guaranteed to get done up front if the paste configuration is correct. I'm not a fan of requiring configuration to get correct behavior, but there's already precedent for that (see the auth middleware) and I didn't want the scope to grow beyond the original bug.
Brian Waldon (bcwaldon) wrote : | # |
Okay, I can definitely understand many of your concerns here. Maybe one day we will get away from paste and our problems will be solved ;)
As for the accept header parsing: I feel like the code in urlmap.py is duplicating functionality provided in webob.acceptparse (http://
Johannes Erdfelt (johannes.erdfelt) wrote : | # |
It *does* duplicate the code in webob, but only because webob ignores all parameters except for 'q'. We need the 'version' parameter as well (which is kind of the purpose of the branch).
I should also mention that the Accept parsing code in this branch is based off of the code in Werkzeug. It's BSD licensed and appears to be compatible with the Apache license Openstack uses. I'm thinking I might need to move that code to a separate file to keep the license separate.
Brian Waldon (bcwaldon) wrote : | # |
Okay, I didn't look close enough at acceptparse to notice it didn't return values other than quality. I'm going to approve with the understanding that some of this will get simplified in the future.
Brian Waldon (bcwaldon) wrote : | # |
Moved to gerrit.
Unmerged revisions
- 1591. By Johannes Erdfelt
-
Add docstrings to tests to better document the intent
- 1590. By Johannes Erdfelt
-
Cleanup docstrings
Remove is_filename option, it's not necessary for us and is a hack anyway - 1589. By Johannes Erdfelt
-
Remove FIXME now that it has a workaround
- 1588. By Johannes Erdfelt
-
Remove hardcoded content type now that the code handles direct API again
- 1587. By Johannes Erdfelt
-
Accept application/
atom+xml for a few exceptions as well - 1586. By Johannes Erdfelt
-
Add content type tests
- 1585. By Johannes Erdfelt
-
PEP8 cleanup
- 1584. By Johannes Erdfelt
-
Merge with trunk
- 1583. By Johannes Erdfelt
-
Start adding some unit tests
- 1582. By Johannes Erdfelt
-
Rename to urlmap to better reflect the purpose
Preview Diff
1 | === modified file 'etc/nova/api-paste.ini' |
2 | --- etc/nova/api-paste.ini 2011-09-20 22:17:03 +0000 |
3 | +++ etc/nova/api-paste.ini 2011-09-21 20:54:24 +0000 |
4 | @@ -71,7 +71,7 @@ |
5 | ############# |
6 | |
7 | [composite:osapi] |
8 | -use = egg:Paste#urlmap |
9 | +use = call:nova.api.openstack.urlmap:urlmap_factory |
10 | /: osversions |
11 | /v1.0: openstackapi10 |
12 | /v1.1: openstackapi11 |
13 | |
14 | === added file 'nova/api/openstack/urlmap.py' |
15 | --- nova/api/openstack/urlmap.py 1970-01-01 00:00:00 +0000 |
16 | +++ nova/api/openstack/urlmap.py 2011-09-21 20:54:24 +0000 |
17 | @@ -0,0 +1,297 @@ |
18 | +# vim: tabstop=4 shiftwidth=4 softtabstop=4 |
19 | + |
20 | +# Copyright 2011 OpenStack LLC. |
21 | +# All Rights Reserved. |
22 | +# |
23 | +# Licensed under the Apache License, Version 2.0 (the "License"); you may |
24 | +# not use this file except in compliance with the License. You may obtain |
25 | +# a copy of the License at |
26 | +# |
27 | +# http://www.apache.org/licenses/LICENSE-2.0 |
28 | +# |
29 | +# Unless required by applicable law or agreed to in writing, software |
30 | +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
31 | +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
32 | +# License for the specific language governing permissions and limitations |
33 | +# under the License. |
34 | + |
35 | +import paste.urlmap |
36 | +import re |
37 | +import urllib2 |
38 | + |
39 | +from nova import log as logging |
40 | +from nova.api.openstack import wsgi |
41 | + |
42 | + |
43 | +_quoted_string_re = r'"[^"\\]*(?:\\.[^"\\]*)*"' |
44 | +_option_header_piece_re = re.compile(r';\s*([^\s;=]+|%s)\s*' |
45 | + r'(?:=\s*([^;]+|%s))?\s*' % |
46 | + (_quoted_string_re, _quoted_string_re)) |
47 | + |
48 | +LOG = logging.getLogger('nova.api.openstack.map') |
49 | + |
50 | + |
51 | +def unquote_header_value(value): |
52 | + """Unquotes a header value. |
53 | + This does not use the real unquoting but what browsers are actually |
54 | + using for quoting. |
55 | + |
56 | + :param value: the header value to unquote. |
57 | + """ |
58 | + if value and value[0] == value[-1] == '"': |
59 | + # this is not the real unquoting, but fixing this so that the |
60 | + # RFC is met will result in bugs with internet explorer and |
61 | + # probably some other browsers as well. IE for example is |
62 | + # uploading files with "C:\foo\bar.txt" as filename |
63 | + value = value[1:-1] |
64 | + return value |
65 | + |
66 | + |
67 | +def parse_list_header(value): |
68 | + """Parse lists as described by RFC 2068 Section 2. |
69 | + |
70 | + In particular, parse comma-separated lists where the elements of |
71 | + the list may include quoted-strings. A quoted-string could |
72 | + contain a comma. A non-quoted string could have quotes in the |
73 | + middle. Quotes are removed automatically after parsing. |
74 | + |
75 | + The return value is a standard :class:`list`: |
76 | + |
77 | + >>> parse_list_header('token, "quoted value"') |
78 | + ['token', 'quoted value'] |
79 | + |
80 | + :param value: a string with a list header. |
81 | + :return: :class:`list` |
82 | + """ |
83 | + result = [] |
84 | + for item in urllib2.parse_http_list(value): |
85 | + if item[:1] == item[-1:] == '"': |
86 | + item = unquote_header_value(item[1:-1]) |
87 | + result.append(item) |
88 | + return result |
89 | + |
90 | + |
91 | +def parse_options_header(value): |
92 | + """Parse a ``Content-Type`` like header into a tuple with the content |
93 | + type and the options: |
94 | + |
95 | + >>> parse_options_header('Content-Type: text/html; mimetype=text/html') |
96 | + ('Content-Type:', {'mimetype': 'text/html'}) |
97 | + |
98 | + :param value: the header to parse. |
99 | + :return: (str, options) |
100 | + """ |
101 | + def _tokenize(string): |
102 | + for match in _option_header_piece_re.finditer(string): |
103 | + key, value = match.groups() |
104 | + key = unquote_header_value(key) |
105 | + if value is not None: |
106 | + value = unquote_header_value(value) |
107 | + yield key, value |
108 | + |
109 | + if not value: |
110 | + return '', {} |
111 | + |
112 | + parts = _tokenize(';' + value) |
113 | + name = parts.next()[0] |
114 | + extra = dict(parts) |
115 | + return name, extra |
116 | + |
117 | + |
118 | +class Accept(object): |
119 | + def __init__(self, value): |
120 | + self._content_types = [parse_options_header(v) for v in |
121 | + parse_list_header(value)] |
122 | + |
123 | + def best_match(self, supported_content_types): |
124 | + # FIXME: Should we have a more sophisticated matching algorithm that |
125 | + # takes into account the version as well? |
126 | + best_quality = -1 |
127 | + best_content_type = None |
128 | + best_params = {} |
129 | + best_match = '*/*' |
130 | + |
131 | + for content_type in supported_content_types: |
132 | + for content_mask, params in self._content_types: |
133 | + try: |
134 | + quality = float(params.get('q', 1)) |
135 | + except ValueError: |
136 | + continue |
137 | + |
138 | + if quality < best_quality: |
139 | + continue |
140 | + elif best_quality == quality: |
141 | + if best_match.count('*') <= content_mask.count('*'): |
142 | + continue |
143 | + |
144 | + if self._match_mask(content_mask, content_type): |
145 | + best_quality = quality |
146 | + best_content_type = content_type |
147 | + best_params = params |
148 | + best_match = content_mask |
149 | + |
150 | + return best_content_type, best_params |
151 | + |
152 | + def content_type_params(self, best_content_type): |
153 | + """Find parameters in Accept header for given content type.""" |
154 | + for content_type, params in self._content_types: |
155 | + if best_content_type == content_type: |
156 | + return params |
157 | + |
158 | + return {} |
159 | + |
160 | + def _match_mask(self, mask, content_type): |
161 | + if '*' not in mask: |
162 | + return content_type == mask |
163 | + if mask == '*/*': |
164 | + return True |
165 | + mask_major = mask[:-2] |
166 | + content_type_major = content_type.split('/', 1)[0] |
167 | + return content_type_major == mask_major |
168 | + |
169 | + |
170 | +def urlmap_factory(loader, global_conf, **local_conf): |
171 | + if 'not_found_app' in local_conf: |
172 | + not_found_app = local_conf.pop('not_found_app') |
173 | + else: |
174 | + not_found_app = global_conf.get('not_found_app') |
175 | + if not_found_app: |
176 | + not_found_app = loader.get_app(not_found_app, global_conf=global_conf) |
177 | + urlmap = URLMap(not_found_app=not_found_app) |
178 | + for path, app_name in local_conf.items(): |
179 | + path = paste.urlmap.parse_path_expression(path) |
180 | + app = loader.get_app(app_name, global_conf=global_conf) |
181 | + urlmap[path] = app |
182 | + return urlmap |
183 | + |
184 | + |
185 | +class URLMap(paste.urlmap.URLMap): |
186 | + def _match(self, host, port, path_info): |
187 | + """Find longest match for a given URL path.""" |
188 | + for (domain, app_url), app in self.applications: |
189 | + if domain and domain != host and domain != host + ':' + port: |
190 | + continue |
191 | + if (path_info == app_url |
192 | + or path_info.startswith(app_url + '/')): |
193 | + return app, app_url |
194 | + |
195 | + return None, None |
196 | + |
197 | + def _set_script_name(self, app, app_url): |
198 | + def wrap(environ, start_response): |
199 | + environ['SCRIPT_NAME'] += app_url |
200 | + return app(environ, start_response) |
201 | + |
202 | + return wrap |
203 | + |
204 | + def _munge_path(self, app, path_info, app_url): |
205 | + def wrap(environ, start_response): |
206 | + environ['SCRIPT_NAME'] += app_url |
207 | + environ['PATH_INFO'] = path_info[len(app_url):] |
208 | + return app(environ, start_response) |
209 | + |
210 | + return wrap |
211 | + |
212 | + def _path_strategy(self, host, port, path_info): |
213 | + """Check path suffix for MIME type and path prefix for API version.""" |
214 | + mime_type = app = app_url = None |
215 | + |
216 | + parts = path_info.rsplit('.', 1) |
217 | + if len(parts) > 1: |
218 | + possible_type = 'application/' + parts[1] |
219 | + if possible_type in wsgi.SUPPORTED_CONTENT_TYPES: |
220 | + mime_type = possible_type |
221 | + |
222 | + parts = path_info.split('/') |
223 | + if len(parts) > 1: |
224 | + possible_app, possible_app_url = self._match(host, port, path_info) |
225 | + # Don't use prefix if it ends up matching default |
226 | + if possible_app and possible_app_url: |
227 | + app_url = possible_app_url |
228 | + app = self._munge_path(possible_app, path_info, app_url) |
229 | + |
230 | + return mime_type, app, app_url |
231 | + |
232 | + def _content_type_strategy(self, host, port, environ): |
233 | + """Check Content-Type header for API version.""" |
234 | + app = None |
235 | + params = parse_options_header(environ.get('CONTENT_TYPE', ''))[1] |
236 | + if 'version' in params: |
237 | + app, app_url = self._match(host, port, '/v' + params['version']) |
238 | + if app: |
239 | + app = self._set_script_name(app, app_url) |
240 | + |
241 | + return app |
242 | + |
243 | + def _accept_strategy(self, host, port, environ, supported_content_types): |
244 | + """Check Accept header for best matching MIME type and API version.""" |
245 | + accept = Accept(environ.get('HTTP_ACCEPT', '')) |
246 | + |
247 | + app = None |
248 | + |
249 | + # Find the best match in the Accept header |
250 | + mime_type, params = accept.best_match(supported_content_types) |
251 | + if 'version' in params: |
252 | + app, app_url = self._match(host, port, '/v' + params['version']) |
253 | + if app: |
254 | + app = self._set_script_name(app, app_url) |
255 | + |
256 | + return mime_type, app |
257 | + |
258 | + def __call__(self, environ, start_response): |
259 | + host = environ.get('HTTP_HOST', environ.get('SERVER_NAME')).lower() |
260 | + if ':' in host: |
261 | + host, port = host.split(':', 1) |
262 | + else: |
263 | + if environ['wsgi.url_scheme'] == 'http': |
264 | + port = '80' |
265 | + else: |
266 | + port = '443' |
267 | + |
268 | + path_info = environ['PATH_INFO'] |
269 | + path_info = self.normalize_url(path_info, False)[1] |
270 | + |
271 | + # The MIME type for the response is determined in one of two ways: |
272 | + # 1) URL path suffix (eg /servers/detail.json) |
273 | + # 2) Accept header (eg application/json;q=0.8, application/xml;q=0.2) |
274 | + |
275 | + # The API version is determined in one of three ways: |
276 | + # 1) URL path prefix (eg /v1.1/tenant/servers/detail) |
277 | + # 2) Content-Type header (eg application/json;version=1.1) |
278 | + # 3) Accept header (eg application/json;q=0.8;version=1.1) |
279 | + |
280 | + supported_content_types = list(wsgi.SUPPORTED_CONTENT_TYPES) |
281 | + |
282 | + mime_type, app, app_url = self._path_strategy(host, port, path_info) |
283 | + |
284 | + # Accept application/atom+xml for the index query of each API |
285 | + # version mount point as well as the root index |
286 | + if (app_url and app_url + '/' == path_info) or path_info == '/': |
287 | + supported_content_types.append('application/atom+xml') |
288 | + |
289 | + if not app: |
290 | + app = self._content_type_strategy(host, port, environ) |
291 | + |
292 | + if not mime_type or not app: |
293 | + possible_mime_type, possible_app = self._accept_strategy( |
294 | + host, port, environ, supported_content_types) |
295 | + if possible_mime_type and not mime_type: |
296 | + mime_type = possible_mime_type |
297 | + if possible_app and not app: |
298 | + app = possible_app |
299 | + |
300 | + if not mime_type: |
301 | + mime_type = 'application/json' |
302 | + |
303 | + if not app: |
304 | + # Didn't match a particular version, probably matches default |
305 | + app, app_url = self._match(host, port, path_info) |
306 | + if app: |
307 | + app = self._munge_path(app, path_info, app_url) |
308 | + |
309 | + if app: |
310 | + environ['nova.best_content_type'] = mime_type |
311 | + return app(environ, start_response) |
312 | + |
313 | + environ['paste.urlmap_object'] = self |
314 | + return self.not_found_application(environ, start_response) |
315 | |
316 | === modified file 'nova/api/openstack/versions.py' |
317 | --- nova/api/openstack/versions.py 2011-09-14 17:32:33 +0000 |
318 | +++ nova/api/openstack/versions.py 2011-09-21 20:54:24 +0000 |
319 | @@ -47,11 +47,11 @@ |
320 | "media-types": [ |
321 | { |
322 | "base": "application/xml", |
323 | - "type": "application/vnd.openstack.compute-v1.0+xml", |
324 | + "type": "application/vnd.openstack.compute+xml;version=1.0", |
325 | }, |
326 | { |
327 | "base": "application/json", |
328 | - "type": "application/vnd.openstack.compute-v1.0+json", |
329 | + "type": "application/vnd.openstack.compute+json;version=1.0", |
330 | } |
331 | ], |
332 | }, |
333 | @@ -76,11 +76,11 @@ |
334 | "media-types": [ |
335 | { |
336 | "base": "application/xml", |
337 | - "type": "application/vnd.openstack.compute-v1.1+xml", |
338 | + "type": "application/vnd.openstack.compute+xml;version=1.1", |
339 | }, |
340 | { |
341 | "base": "application/json", |
342 | - "type": "application/vnd.openstack.compute-v1.1+json", |
343 | + "type": "application/vnd.openstack.compute+json;version=1.1", |
344 | } |
345 | ], |
346 | }, |
347 | @@ -106,13 +106,7 @@ |
348 | body_serializers=body_serializers, |
349 | headers_serializer=headers_serializer) |
350 | |
351 | - supported_content_types = ('application/json', |
352 | - 'application/vnd.openstack.compute+json', |
353 | - 'application/xml', |
354 | - 'application/vnd.openstack.compute+xml', |
355 | - 'application/atom+xml') |
356 | - deserializer = VersionsRequestDeserializer( |
357 | - supported_content_types=supported_content_types) |
358 | + deserializer = VersionsRequestDeserializer() |
359 | |
360 | wsgi.Resource.__init__(self, None, serializer=serializer, |
361 | deserializer=deserializer) |
362 | @@ -141,15 +135,6 @@ |
363 | |
364 | |
365 | class VersionsRequestDeserializer(wsgi.RequestDeserializer): |
366 | - def get_expected_content_type(self, request): |
367 | - supported_content_types = list(self.supported_content_types) |
368 | - if request.path != '/': |
369 | - # Remove atom+xml accept type for 300 responses |
370 | - if 'application/atom+xml' in supported_content_types: |
371 | - supported_content_types.remove('application/atom+xml') |
372 | - |
373 | - return request.best_match_content_type(supported_content_types) |
374 | - |
375 | def get_action_args(self, request_environment): |
376 | """Parse dictionary created by routes library.""" |
377 | args = {} |
378 | @@ -309,13 +294,7 @@ |
379 | } |
380 | serializer = wsgi.ResponseSerializer(body_serializers) |
381 | |
382 | - supported_content_types = ('application/json', |
383 | - 'application/vnd.openstack.compute+json', |
384 | - 'application/xml', |
385 | - 'application/vnd.openstack.compute+xml', |
386 | - 'application/atom+xml') |
387 | - deserializer = wsgi.RequestDeserializer( |
388 | - supported_content_types=supported_content_types) |
389 | + deserializer = wsgi.RequestDeserializer() |
390 | |
391 | return wsgi.Resource(controller, serializer=serializer, |
392 | deserializer=deserializer) |
393 | |
394 | === modified file 'nova/api/openstack/wsgi.py' |
395 | --- nova/api/openstack/wsgi.py 2011-09-15 18:32:10 +0000 |
396 | +++ nova/api/openstack/wsgi.py 2011-09-21 20:54:24 +0000 |
397 | @@ -43,7 +43,7 @@ |
398 | 'application/vnd.openstack.compute+xml': 'application/xml', |
399 | } |
400 | |
401 | -_SUPPORTED_CONTENT_TYPES = ( |
402 | +SUPPORTED_CONTENT_TYPES = ( |
403 | 'application/json', |
404 | 'application/vnd.openstack.compute+json', |
405 | 'application/xml', |
406 | @@ -54,25 +54,26 @@ |
407 | class Request(webob.Request): |
408 | """Add some Openstack API-specific logic to the base webob.Request.""" |
409 | |
410 | - def best_match_content_type(self, supported_content_types=None): |
411 | - """Determine the requested response content-type. |
412 | - |
413 | - Based on the query extension then the Accept header. |
414 | - |
415 | - """ |
416 | - supported_content_types = supported_content_types or \ |
417 | - _SUPPORTED_CONTENT_TYPES |
418 | - |
419 | - parts = self.path.rsplit('.', 1) |
420 | - if len(parts) > 1: |
421 | - ctype = 'application/{0}'.format(parts[1]) |
422 | - if ctype in supported_content_types: |
423 | - return ctype |
424 | - |
425 | - bm = self.accept.best_match(supported_content_types) |
426 | - |
427 | - # default to application/json if we don't find a preference |
428 | - return bm or 'application/json' |
429 | + def best_match_content_type(self): |
430 | + """Determine the requested response content-type.""" |
431 | + if 'nova.best_content_type' not in self.environ: |
432 | + # Calculate the best MIME type |
433 | + content_type = None |
434 | + |
435 | + # Check URL path suffix |
436 | + parts = self.path.rsplit('.', 1) |
437 | + if len(parts) > 1: |
438 | + possible_type = 'application/' + parts[1] |
439 | + if possible_type in SUPPORTED_CONTENT_TYPES: |
440 | + content_type = possible_type |
441 | + |
442 | + if not content_type: |
443 | + content_type = self.accept.best_match(SUPPORTED_CONTENT_TYPES) |
444 | + |
445 | + self.environ['nova.best_content_type'] = content_type or \ |
446 | + 'application/json' |
447 | + |
448 | + return self.environ['nova.best_content_type'] |
449 | |
450 | def get_content_type(self): |
451 | """Determine content type of the request body. |
452 | @@ -83,7 +84,7 @@ |
453 | if not "Content-Type" in self.headers: |
454 | return None |
455 | |
456 | - allowed_types = _SUPPORTED_CONTENT_TYPES |
457 | + allowed_types = SUPPORTED_CONTENT_TYPES |
458 | content_type = self.content_type |
459 | |
460 | if content_type not in allowed_types: |
461 | @@ -219,12 +220,7 @@ |
462 | class RequestDeserializer(object): |
463 | """Break up a Request object into more useful pieces.""" |
464 | |
465 | - def __init__(self, body_deserializers=None, headers_deserializer=None, |
466 | - supported_content_types=None): |
467 | - |
468 | - self.supported_content_types = supported_content_types or \ |
469 | - _SUPPORTED_CONTENT_TYPES |
470 | - |
471 | + def __init__(self, body_deserializers=None, headers_deserializer=None): |
472 | self.body_deserializers = { |
473 | 'application/xml': XMLDeserializer(), |
474 | 'application/json': JSONDeserializer(), |
475 | @@ -287,7 +283,7 @@ |
476 | raise exception.InvalidContentType(content_type=content_type) |
477 | |
478 | def get_expected_content_type(self, request): |
479 | - return request.best_match_content_type(self.supported_content_types) |
480 | + return request.best_match_content_type() |
481 | |
482 | def get_action_args(self, request_environment): |
483 | """Parse dictionary created by routes library.""" |
484 | |
485 | === modified file 'nova/tests/api/openstack/fakes.py' |
486 | --- nova/tests/api/openstack/fakes.py 2011-09-12 21:52:29 +0000 |
487 | +++ nova/tests/api/openstack/fakes.py 2011-09-21 20:54:24 +0000 |
488 | @@ -21,7 +21,6 @@ |
489 | |
490 | import webob |
491 | import webob.dec |
492 | -from paste import urlmap |
493 | |
494 | from glance import client as glance_client |
495 | from glance.common import exception as glance_exc |
496 | @@ -37,6 +36,7 @@ |
497 | from nova.api.openstack import extensions |
498 | from nova.api.openstack import versions |
499 | from nova.api.openstack import limits |
500 | +from nova.api.openstack import urlmap |
501 | from nova.auth.manager import User, Project |
502 | import nova.image.fake |
503 | from nova.image import glance |
504 | |
505 | === added file 'nova/tests/api/openstack/test_urlmap.py' |
506 | --- nova/tests/api/openstack/test_urlmap.py 1970-01-01 00:00:00 +0000 |
507 | +++ nova/tests/api/openstack/test_urlmap.py 2011-09-21 20:54:24 +0000 |
508 | @@ -0,0 +1,111 @@ |
509 | +# Copyright 2011 OpenStack LLC. |
510 | +# All Rights Reserved. |
511 | +# |
512 | +# Licensed under the Apache License, Version 2.0 (the "License"); you may |
513 | +# not use this file except in compliance with the License. You may obtain |
514 | +# a copy of the License at |
515 | +# |
516 | +# http://www.apache.org/licenses/LICENSE-2.0 |
517 | +# |
518 | +# Unless required by applicable law or agreed to in writing, software |
519 | +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
520 | +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
521 | +# License for the specific language governing permissions and limitations |
522 | +# under the License. |
523 | + |
524 | +import json |
525 | +import webob |
526 | + |
527 | +from nova import test |
528 | +from nova import log as logging |
529 | +from nova.tests.api.openstack import fakes |
530 | + |
531 | +LOG = logging.getLogger('nova.tests.api.openstack.test_urlmap') |
532 | + |
533 | + |
534 | +class UrlmapTest(test.TestCase): |
535 | + def setUp(self): |
536 | + super(UrlmapTest, self).setUp() |
537 | + fakes.stub_out_rate_limiting(self.stubs) |
538 | + |
539 | + def test_path_version_v1_0(self): |
540 | + """Test URL path specifying v1.0 returns v1.0 content.""" |
541 | + req = webob.Request.blank('/v1.0/') |
542 | + req.accept = "application/json" |
543 | + res = req.get_response(fakes.wsgi_app()) |
544 | + self.assertEqual(res.status_int, 200) |
545 | + self.assertEqual(res.content_type, "application/json") |
546 | + body = json.loads(res.body) |
547 | + self.assertEqual(body['version']['id'], 'v1.0') |
548 | + |
549 | + def test_path_version_v1_1(self): |
550 | + """Test URL path specifying v1.1 returns v1.1 content.""" |
551 | + req = webob.Request.blank('/v1.1/') |
552 | + req.accept = "application/json" |
553 | + res = req.get_response(fakes.wsgi_app()) |
554 | + self.assertEqual(res.status_int, 200) |
555 | + self.assertEqual(res.content_type, "application/json") |
556 | + body = json.loads(res.body) |
557 | + self.assertEqual(body['version']['id'], 'v1.1') |
558 | + |
559 | + def test_content_type_version_v1_0(self): |
560 | + """Test Content-Type specifying v1.0 returns v1.0 content.""" |
561 | + req = webob.Request.blank('/') |
562 | + req.content_type = "application/json;version=1.0" |
563 | + req.accept = "application/json" |
564 | + res = req.get_response(fakes.wsgi_app()) |
565 | + self.assertEqual(res.status_int, 200) |
566 | + self.assertEqual(res.content_type, "application/json") |
567 | + body = json.loads(res.body) |
568 | + self.assertEqual(body['version']['id'], 'v1.0') |
569 | + |
570 | + def test_content_type_version_v1_1(self): |
571 | + """Test Content-Type specifying v1.1 returns v1.1 content.""" |
572 | + req = webob.Request.blank('/') |
573 | + req.content_type = "application/json;version=1.1" |
574 | + req.accept = "application/json" |
575 | + res = req.get_response(fakes.wsgi_app()) |
576 | + self.assertEqual(res.status_int, 200) |
577 | + self.assertEqual(res.content_type, "application/json") |
578 | + body = json.loads(res.body) |
579 | + self.assertEqual(body['version']['id'], 'v1.1') |
580 | + |
581 | + def test_accept_version_v1_0(self): |
582 | + """Test Accept header specifying v1.0 returns v1.0 content.""" |
583 | + req = webob.Request.blank('/') |
584 | + req.accept = "application/json;version=1.0" |
585 | + res = req.get_response(fakes.wsgi_app()) |
586 | + self.assertEqual(res.status_int, 200) |
587 | + self.assertEqual(res.content_type, "application/json") |
588 | + body = json.loads(res.body) |
589 | + self.assertEqual(body['version']['id'], 'v1.0') |
590 | + |
591 | + def test_accept_version_v1_1(self): |
592 | + """Test Accept header specifying v1.1 returns v1.1 content.""" |
593 | + req = webob.Request.blank('/') |
594 | + req.accept = "application/json;version=1.1" |
595 | + res = req.get_response(fakes.wsgi_app()) |
596 | + self.assertEqual(res.status_int, 200) |
597 | + self.assertEqual(res.content_type, "application/json") |
598 | + body = json.loads(res.body) |
599 | + self.assertEqual(body['version']['id'], 'v1.1') |
600 | + |
601 | + def test_path_content_type(self): |
602 | + """Test URL path specifying JSON returns JSON content.""" |
603 | + req = webob.Request.blank('/v1.1/foobar/images/1.json') |
604 | + req.accept = "application/xml" |
605 | + res = req.get_response(fakes.wsgi_app()) |
606 | + self.assertEqual(res.status_int, 200) |
607 | + self.assertEqual(res.content_type, "application/json") |
608 | + body = json.loads(res.body) |
609 | + self.assertEqual(body['image']['id'], '1') |
610 | + |
611 | + def test_accept_content_type(self): |
612 | + """Test Accept header specifying JSON returns JSON content.""" |
613 | + req = webob.Request.blank('/v1.1/foobar/images/1') |
614 | + req.accept = "application/xml;q=0.8, application/json" |
615 | + res = req.get_response(fakes.wsgi_app()) |
616 | + self.assertEqual(res.status_int, 200) |
617 | + self.assertEqual(res.content_type, "application/json") |
618 | + body = json.loads(res.body) |
619 | + self.assertEqual(body['image']['id'], '1') |
620 | |
621 | === modified file 'nova/tests/api/openstack/test_versions.py' |
622 | --- nova/tests/api/openstack/test_versions.py 2011-09-13 21:09:25 +0000 |
623 | +++ nova/tests/api/openstack/test_versions.py 2011-09-21 20:54:24 +0000 |
624 | @@ -55,11 +55,11 @@ |
625 | "media-types": [ |
626 | { |
627 | "base": "application/xml", |
628 | - "type": "application/vnd.openstack.compute-v1.0+xml", |
629 | + "type": "application/vnd.openstack.compute+xml;version=1.0", |
630 | }, |
631 | { |
632 | "base": "application/json", |
633 | - "type": "application/vnd.openstack.compute-v1.0+json", |
634 | + "type": "application/vnd.openstack.compute+json;version=1.0", |
635 | }, |
636 | ], |
637 | }, |
638 | @@ -84,11 +84,11 @@ |
639 | "media-types": [ |
640 | { |
641 | "base": "application/xml", |
642 | - "type": "application/vnd.openstack.compute-v1.1+xml", |
643 | + "type": "application/vnd.openstack.compute+xml;version=1.1", |
644 | }, |
645 | { |
646 | "base": "application/json", |
647 | - "type": "application/vnd.openstack.compute-v1.1+json", |
648 | + "type": "application/vnd.openstack.compute+json;version=1.1", |
649 | }, |
650 | ], |
651 | }, |
652 | @@ -174,12 +174,12 @@ |
653 | { |
654 | "base": "application/xml", |
655 | "type": "application/" |
656 | - "vnd.openstack.compute-v1.0+xml", |
657 | + "vnd.openstack.compute+xml;version=1.0", |
658 | }, |
659 | { |
660 | "base": "application/json", |
661 | "type": "application/" |
662 | - "vnd.openstack.compute-v1.0+json", |
663 | + "vnd.openstack.compute+json;version=1.0", |
664 | }, |
665 | ], |
666 | }, |
667 | @@ -220,12 +220,58 @@ |
668 | { |
669 | "base": "application/xml", |
670 | "type": "application/" |
671 | - "vnd.openstack.compute-v1.1+xml", |
672 | - }, |
673 | - { |
674 | - "base": "application/json", |
675 | - "type": "application/" |
676 | - "vnd.openstack.compute-v1.1+json", |
677 | + "vnd.openstack.compute+xml;version=1.1", |
678 | + }, |
679 | + { |
680 | + "base": "application/json", |
681 | + "type": "application/" |
682 | + "vnd.openstack.compute+json;version=1.1", |
683 | + }, |
684 | + ], |
685 | + }, |
686 | + } |
687 | + self.assertEqual(expected, version) |
688 | + |
689 | + def test_get_version_1_1_detail_content_type(self): |
690 | + req = webob.Request.blank('/') |
691 | + req.accept = "application/json;version=1.1" |
692 | + res = req.get_response(fakes.wsgi_app()) |
693 | + self.assertEqual(res.status_int, 200) |
694 | + self.assertEqual(res.content_type, "application/json") |
695 | + version = json.loads(res.body) |
696 | + expected = { |
697 | + "version": { |
698 | + "id": "v1.1", |
699 | + "status": "CURRENT", |
700 | + "updated": "2011-01-21T11:33:21Z", |
701 | + "links": [ |
702 | + { |
703 | + "rel": "self", |
704 | + "href": "http://localhost/v1.1/", |
705 | + }, |
706 | + { |
707 | + "rel": "describedby", |
708 | + "type": "application/pdf", |
709 | + "href": "http://docs.rackspacecloud.com/" |
710 | + "servers/api/v1.1/cs-devguide-20110125.pdf", |
711 | + }, |
712 | + { |
713 | + "rel": "describedby", |
714 | + "type": "application/vnd.sun.wadl+xml", |
715 | + "href": "http://docs.rackspacecloud.com/" |
716 | + "servers/api/v1.1/application.wadl", |
717 | + }, |
718 | + ], |
719 | + "media-types": [ |
720 | + { |
721 | + "base": "application/xml", |
722 | + "type": "application/" |
723 | + "vnd.openstack.compute+xml;version=1.1", |
724 | + }, |
725 | + { |
726 | + "base": "application/json", |
727 | + "type": "application/" |
728 | + "vnd.openstack.compute+json;version=1.1", |
729 | }, |
730 | ], |
731 | }, |
732 | @@ -433,11 +479,13 @@ |
733 | "media-types": [ |
734 | { |
735 | "base": "application/xml", |
736 | - "type": "application/vnd.openstack.compute-v1.1+xml" |
737 | + "type": "application/vnd.openstack.compute+xml" |
738 | + ";version=1.1" |
739 | }, |
740 | { |
741 | "base": "application/json", |
742 | - "type": "application/vnd.openstack.compute-v1.1+json" |
743 | + "type": "application/vnd.openstack.compute+json" |
744 | + ";version=1.1" |
745 | }, |
746 | ], |
747 | }, |
748 | @@ -453,11 +501,13 @@ |
749 | "media-types": [ |
750 | { |
751 | "base": "application/xml", |
752 | - "type": "application/vnd.openstack.compute-v1.0+xml" |
753 | + "type": "application/vnd.openstack.compute+xml" |
754 | + ";version=1.0" |
755 | }, |
756 | { |
757 | "base": "application/json", |
758 | - "type": "application/vnd.openstack.compute-v1.0+json" |
759 | + "type": "application/vnd.openstack.compute+json" |
760 | + ";version=1.0" |
761 | }, |
762 | ], |
763 | }, |
764 | @@ -531,11 +581,13 @@ |
765 | "media-types": [ |
766 | { |
767 | "base": "application/xml", |
768 | - "type": "application/vnd.openstack.compute-v1.1+xml" |
769 | + "type": "application/vnd.openstack.compute+xml" |
770 | + ";version=1.1" |
771 | }, |
772 | { |
773 | "base": "application/json", |
774 | - "type": "application/vnd.openstack.compute-v1.1+json" |
775 | + "type": "application/vnd.openstack.compute+json" |
776 | + ";version=1.1" |
777 | }, |
778 | ], |
779 | }, |
780 | @@ -551,11 +603,13 @@ |
781 | "media-types": [ |
782 | { |
783 | "base": "application/xml", |
784 | - "type": "application/vnd.openstack.compute-v1.0+xml" |
785 | + "type": "application/vnd.openstack.compute+xml" |
786 | + ";version=1.0" |
787 | }, |
788 | { |
789 | "base": "application/json", |
790 | - "type": "application/vnd.openstack.compute-v1.0+json" |
791 | + "type": "application/vnd.openstack.compute+json" |
792 | + ";version=1.0" |
793 | }, |
794 | ], |
795 | }, |
796 | @@ -713,11 +767,13 @@ |
797 | "media-types": [ |
798 | { |
799 | "base": "application/xml", |
800 | - "type": "application/vnd.openstack.compute-v1.0+xml", |
801 | + "type": "application/vnd.openstack.compute+xml" |
802 | + ";version=1.0", |
803 | }, |
804 | { |
805 | "base": "application/json", |
806 | - "type": "application/vnd.openstack.compute-v1.0+json", |
807 | + "type": "application/vnd.openstack.compute+json" |
808 | + ";version=1.0", |
809 | }, |
810 | ], |
811 | }, |
812 | @@ -817,11 +873,13 @@ |
813 | "media-types": [ |
814 | { |
815 | "base": "application/xml", |
816 | - "type": "application/vnd.openstack.compute-v1.1+xml", |
817 | + "type": "application/vnd.openstack.compute+xml" |
818 | + ";version=1.1", |
819 | }, |
820 | { |
821 | "base": "application/json", |
822 | - "type": "application/vnd.openstack.compute-v1.1+json", |
823 | + "type": "application/vnd.openstack.compute+json" |
824 | + ";version=1.1", |
825 | } |
826 | ], |
827 | }, |
Finding it hard to see that the new code is being properly covered by the tests.
Ideally I'd like to see explicit unit tests on each of the new methods added (and ensuring they hit each of the conditional branches).
At the very least, some description in the unit tests as to what they are actually testing and which methods are being touched. But ideally, simple single-method unit tests.