1
# vim: tabstop=4 shiftwidth=4 softtabstop=4
3
# Copyright 2011 OpenStack LLC.
6
# Licensed under the Apache License, Version 2.0 (the "License"); you may
7
# not use this file except in compliance with the License. You may obtain
8
# a copy of the License at
10
# http://www.apache.org/licenses/LICENSE-2.0
12
# Unless required by applicable law or agreed to in writing, software
13
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15
# License for the specific language governing permissions and limitations
22
from nova import log as logging
23
from nova.api.openstack import wsgi
26
_quoted_string_re = r'"[^"\\]*(?:\\.[^"\\]*)*"'
27
_option_header_piece_re = re.compile(r';\s*([^\s;=]+|%s)\s*'
28
r'(?:=\s*([^;]+|%s))?\s*' %
29
(_quoted_string_re, _quoted_string_re))
31
LOG = logging.getLogger('nova.api.openstack.compute.map')
34
def unquote_header_value(value):
35
"""Unquotes a header value.
36
This does not use the real unquoting but what browsers are actually
39
:param value: the header value to unquote.
41
if value and value[0] == value[-1] == '"':
42
# this is not the real unquoting, but fixing this so that the
43
# RFC is met will result in bugs with internet explorer and
44
# probably some other browsers as well. IE for example is
45
# uploading files with "C:\foo\bar.txt" as filename
50
def parse_list_header(value):
51
"""Parse lists as described by RFC 2068 Section 2.
53
In particular, parse comma-separated lists where the elements of
54
the list may include quoted-strings. A quoted-string could
55
contain a comma. A non-quoted string could have quotes in the
56
middle. Quotes are removed automatically after parsing.
58
The return value is a standard :class:`list`:
60
>>> parse_list_header('token, "quoted value"')
61
['token', 'quoted value']
63
:param value: a string with a list header.
64
:return: :class:`list`
67
for item in urllib2.parse_http_list(value):
68
if item[:1] == item[-1:] == '"':
69
item = unquote_header_value(item[1:-1])
74
def parse_options_header(value):
75
"""Parse a ``Content-Type`` like header into a tuple with the content
78
>>> parse_options_header('Content-Type: text/html; mimetype=text/html')
79
('Content-Type:', {'mimetype': 'text/html'})
81
:param value: the header to parse.
82
:return: (str, options)
84
def _tokenize(string):
85
for match in _option_header_piece_re.finditer(string):
86
key, value = match.groups()
87
key = unquote_header_value(key)
89
value = unquote_header_value(value)
95
parts = _tokenize(';' + value)
96
name = parts.next()[0]
101
class Accept(object):
102
def __init__(self, value):
103
self._content_types = [parse_options_header(v) for v in
104
parse_list_header(value)]
106
def best_match(self, supported_content_types):
107
# FIXME: Should we have a more sophisticated matching algorithm that
108
# takes into account the version as well?
110
best_content_type = None
114
for content_type in supported_content_types:
115
for content_mask, params in self._content_types:
117
quality = float(params.get('q', 1))
121
if quality < best_quality:
123
elif best_quality == quality:
124
if best_match.count('*') <= content_mask.count('*'):
127
if self._match_mask(content_mask, content_type):
128
best_quality = quality
129
best_content_type = content_type
131
best_match = content_mask
133
return best_content_type, best_params
135
def content_type_params(self, best_content_type):
136
"""Find parameters in Accept header for given content type."""
137
for content_type, params in self._content_types:
138
if best_content_type == content_type:
143
def _match_mask(self, mask, content_type):
145
return content_type == mask
148
mask_major = mask[:-2]
149
content_type_major = content_type.split('/', 1)[0]
150
return content_type_major == mask_major
153
def urlmap_factory(loader, global_conf, **local_conf):
154
if 'not_found_app' in local_conf:
155
not_found_app = local_conf.pop('not_found_app')
157
not_found_app = global_conf.get('not_found_app')
159
not_found_app = loader.get_app(not_found_app, global_conf=global_conf)
160
urlmap = URLMap(not_found_app=not_found_app)
161
for path, app_name in local_conf.items():
162
path = paste.urlmap.parse_path_expression(path)
163
app = loader.get_app(app_name, global_conf=global_conf)
168
class URLMap(paste.urlmap.URLMap):
169
def _match(self, host, port, path_info):
170
"""Find longest match for a given URL path."""
171
for (domain, app_url), app in self.applications:
172
if domain and domain != host and domain != host + ':' + port:
174
if (path_info == app_url
175
or path_info.startswith(app_url + '/')):
180
def _set_script_name(self, app, app_url):
181
def wrap(environ, start_response):
182
environ['SCRIPT_NAME'] += app_url
183
return app(environ, start_response)
187
def _munge_path(self, app, path_info, app_url):
188
def wrap(environ, start_response):
189
environ['SCRIPT_NAME'] += app_url
190
environ['PATH_INFO'] = path_info[len(app_url):]
191
return app(environ, start_response)
195
def _path_strategy(self, host, port, path_info):
196
"""Check path suffix for MIME type and path prefix for API version."""
197
mime_type = app = app_url = None
199
parts = path_info.rsplit('.', 1)
201
possible_type = 'application/' + parts[1]
202
if possible_type in wsgi.SUPPORTED_CONTENT_TYPES:
203
mime_type = possible_type
205
parts = path_info.split('/')
207
possible_app, possible_app_url = self._match(host, port, path_info)
208
# Don't use prefix if it ends up matching default
209
if possible_app and possible_app_url:
210
app_url = possible_app_url
211
app = self._munge_path(possible_app, path_info, app_url)
213
return mime_type, app, app_url
215
def _content_type_strategy(self, host, port, environ):
216
"""Check Content-Type header for API version."""
218
params = parse_options_header(environ.get('CONTENT_TYPE', ''))[1]
219
if 'version' in params:
220
app, app_url = self._match(host, port, '/v' + params['version'])
222
app = self._set_script_name(app, app_url)
226
def _accept_strategy(self, host, port, environ, supported_content_types):
227
"""Check Accept header for best matching MIME type and API version."""
228
accept = Accept(environ.get('HTTP_ACCEPT', ''))
232
# Find the best match in the Accept header
233
mime_type, params = accept.best_match(supported_content_types)
234
if 'version' in params:
235
app, app_url = self._match(host, port, '/v' + params['version'])
237
app = self._set_script_name(app, app_url)
239
return mime_type, app
241
def __call__(self, environ, start_response):
242
host = environ.get('HTTP_HOST', environ.get('SERVER_NAME')).lower()
244
host, port = host.split(':', 1)
246
if environ['wsgi.url_scheme'] == 'http':
251
path_info = environ['PATH_INFO']
252
path_info = self.normalize_url(path_info, False)[1]
254
# The MIME type for the response is determined in one of two ways:
255
# 1) URL path suffix (eg /servers/detail.json)
256
# 2) Accept header (eg application/json;q=0.8, application/xml;q=0.2)
258
# The API version is determined in one of three ways:
259
# 1) URL path prefix (eg /v1.1/tenant/servers/detail)
260
# 2) Content-Type header (eg application/json;version=1.1)
261
# 3) Accept header (eg application/json;q=0.8;version=1.1)
263
supported_content_types = list(wsgi.SUPPORTED_CONTENT_TYPES)
265
mime_type, app, app_url = self._path_strategy(host, port, path_info)
267
# Accept application/atom+xml for the index query of each API
268
# version mount point as well as the root index
269
if (app_url and app_url + '/' == path_info) or path_info == '/':
270
supported_content_types.append('application/atom+xml')
273
app = self._content_type_strategy(host, port, environ)
275
if not mime_type or not app:
276
possible_mime_type, possible_app = self._accept_strategy(
277
host, port, environ, supported_content_types)
278
if possible_mime_type and not mime_type:
279
mime_type = possible_mime_type
280
if possible_app and not app:
284
mime_type = 'application/json'
287
# Didn't match a particular version, probably matches default
288
app, app_url = self._match(host, port, path_info)
290
app = self._munge_path(app, path_info, app_url)
293
environ['nova.best_content_type'] = mime_type
294
return app(environ, start_response)
296
environ['paste.urlmap_object'] = self
297
return self.not_found_application(environ, start_response)