Coverage for nova/api/openstack/wsgi.py: 93%
493 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-17 15:08 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-17 15:08 +0000
1# Copyright 2013 IBM Corp.
2# Copyright 2011 OpenStack Foundation
3# All Rights Reserved.
4#
5# Licensed under the Apache License, Version 2.0 (the "License"); you may
6# not use this file except in compliance with the License. You may obtain
7# a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14# License for the specific language governing permissions and limitations
15# under the License.
17import functools
18import typing as ty
20import microversion_parse
21from oslo_log import log as logging
22from oslo_serialization import jsonutils
23from oslo_utils import encodeutils
24from oslo_utils import strutils
25import webob
27from nova.api.openstack import api_version_request as api_version
28from nova.api.openstack import versioned_method
29from nova.api import wsgi
30from nova import exception
31from nova import i18n
32from nova.i18n import _
33from nova import version
36LOG = logging.getLogger(__name__)
38_SUPPORTED_CONTENT_TYPES = (
39 'application/json',
40 'application/vnd.openstack.compute+json',
41)
43# These are typically automatically created by routes as either defaults
44# collection or member methods.
45_ROUTES_METHODS = [
46 'create',
47 'delete',
48 'show',
49 'update',
50]
52_METHODS_WITH_BODY = [
53 'POST',
54 'PUT',
55]
57# The default api version request if none is requested in the headers
58# Note(cyeoh): This only applies for the v2.1 API once microversions
59# support is fully merged. It does not affect the V2 API.
60DEFAULT_API_VERSION = "2.1"
62# name of attribute to keep version method information
63VER_METHOD_ATTR = 'versioned_methods'
65# Names of headers used by clients to request a specific version
66# of the REST API
67API_VERSION_REQUEST_HEADER = 'OpenStack-API-Version'
68LEGACY_API_VERSION_REQUEST_HEADER = 'X-OpenStack-Nova-API-Version'
71ENV_LEGACY_V2 = 'openstack.legacy_v2'
74def get_supported_content_types():
75 return _SUPPORTED_CONTENT_TYPES
78class Request(wsgi.Request):
79 """Add some OpenStack API-specific logic to the base webob.Request."""
81 def __init__(self, *args, **kwargs):
82 super(Request, self).__init__(*args, **kwargs)
83 if not hasattr(self, 'api_version_request'):
84 self.api_version_request = api_version.APIVersionRequest()
86 def best_match_content_type(self):
87 """Determine the requested response content-type."""
88 if 'nova.best_content_type' not in self.environ:
89 # Calculate the best MIME type
90 content_type = None
92 # Check URL path suffix
93 parts = self.path.rsplit('.', 1)
94 if len(parts) > 1:
95 possible_type = 'application/' + parts[1]
96 if possible_type in get_supported_content_types(): 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true
97 content_type = possible_type
99 if not content_type: 99 ↛ 105line 99 didn't jump to line 105 because the condition on line 99 was always true
100 best_matches = self.accept.acceptable_offers(
101 get_supported_content_types())
102 if best_matches:
103 content_type = best_matches[0][0]
105 self.environ['nova.best_content_type'] = (content_type or
106 'application/json')
108 return self.environ['nova.best_content_type']
110 def get_content_type(self):
111 """Determine content type of the request body.
113 Does not do any body introspection, only checks header
115 """
116 if "Content-Type" not in self.headers:
117 return None
119 content_type = self.content_type
121 # NOTE(markmc): text/plain is the default for eventlet and
122 # other webservers which use mimetools.Message.gettype()
123 # whereas twisted defaults to ''.
124 if not content_type or content_type == 'text/plain': 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true
125 return None
127 if content_type not in get_supported_content_types():
128 raise exception.InvalidContentType(content_type=content_type)
130 return content_type
132 def best_match_language(self):
133 """Determine the best available language for the request.
135 :returns: the best language match or None if the 'Accept-Language'
136 header was not available in the request.
137 """
138 if not self.accept_language:
139 return None
141 # NOTE(takashin): To decide the default behavior, 'default' is
142 # preferred over 'default_tag' because that is return as it is when
143 # no match. This is also little tricky that 'default' value cannot be
144 # None. At least one of default_tag or default must be supplied as
145 # an argument to the method, to define the defaulting behavior.
146 # So passing a sentinel value to return None from this function.
147 best_match = self.accept_language.lookup(
148 i18n.get_available_languages(), default='fake_LANG')
150 if best_match == 'fake_LANG':
151 best_match = None
152 return best_match
154 def set_api_version_request(self):
155 """Set API version request based on the request header information."""
156 hdr_string = microversion_parse.get_version(
157 self.headers, service_type='compute',
158 legacy_headers=[LEGACY_API_VERSION_REQUEST_HEADER])
160 if hdr_string is None:
161 self.api_version_request = api_version.APIVersionRequest(
162 api_version.DEFAULT_API_VERSION)
163 elif hdr_string == 'latest':
164 # 'latest' is a special keyword which is equivalent to
165 # requesting the maximum version of the API supported
166 self.api_version_request = api_version.max_api_version()
167 else:
168 self.api_version_request = api_version.APIVersionRequest(
169 hdr_string)
171 # Check that the version requested is within the global
172 # minimum/maximum of supported API versions
173 if not self.api_version_request.matches(
174 api_version.min_api_version(),
175 api_version.max_api_version()):
176 raise exception.InvalidGlobalAPIVersion(
177 req_ver=self.api_version_request.get_string(),
178 min_ver=api_version.min_api_version().get_string(),
179 max_ver=api_version.max_api_version().get_string())
181 def set_legacy_v2(self):
182 self.environ[ENV_LEGACY_V2] = True
184 def is_legacy_v2(self):
185 return self.environ.get(ENV_LEGACY_V2, False)
188class ActionDispatcher(object):
189 """Maps method name to local methods through action name."""
191 def dispatch(self, *args, **kwargs):
192 """Find and call local method."""
193 action = kwargs.pop('action', 'default')
194 action_method = getattr(self, str(action), self.default)
195 return action_method(*args, **kwargs)
197 def default(self, data):
198 raise NotImplementedError()
201class JSONDeserializer(ActionDispatcher):
203 def _from_json(self, datastring):
204 try:
205 return jsonutils.loads(datastring)
206 except ValueError:
207 msg = _("cannot understand JSON")
208 raise exception.MalformedRequestBody(reason=msg)
210 def deserialize(self, datastring, action='default'):
211 return self.dispatch(datastring, action=action)
213 def default(self, datastring):
214 return {'body': self._from_json(datastring)}
217class JSONDictSerializer(ActionDispatcher):
218 """Default JSON request body serialization."""
220 def serialize(self, data, action='default'):
221 return self.dispatch(data, action=action)
223 def default(self, data):
224 return str(jsonutils.dumps(data))
227class WSGICodes:
228 """A microversion-aware WSGI code decorator.
230 Allow definition and retrieval of WSGI return codes on a microversion-aware
231 basis.
232 """
234 def __init__(self) -> None:
235 self._codes: list[tuple[int, ty.Optional[str], ty.Optional[str]]] = []
237 def add_code(
238 self, code: tuple[int, ty.Optional[str], ty.Optional[str]]
239 ) -> None:
240 self._codes.append(code)
242 def __call__(self, req: Request) -> int:
243 ver = req.api_version_request
245 for code, min_version, max_version in self._codes: 245 ↛ 251line 245 didn't jump to line 251 because the loop on line 245 didn't complete
246 min_ver = api_version.APIVersionRequest(min_version)
247 max_ver = api_version.APIVersionRequest(max_version)
248 if ver.matches(min_ver, max_ver): 248 ↛ 245line 248 didn't jump to line 245 because the condition on line 248 was always true
249 return code
251 LOG.error("Unknown return code in API method")
252 msg = _("Unknown return code in API method")
253 raise webob.exc.HTTPInternalServerError(explanation=msg)
256def response(
257 code: int,
258 min_version: ty.Optional[str] = None,
259 max_version: ty.Optional[str] = None,
260):
261 """Attaches response code to a method.
263 This decorator associates a response code with a method. Note
264 that the function attributes are directly manipulated; the method
265 is not wrapped.
266 """
268 def decorator(func):
269 if not hasattr(func, 'wsgi_codes'): 269 ↛ 271line 269 didn't jump to line 271 because the condition on line 269 was always true
270 func.wsgi_codes = WSGICodes()
271 func.wsgi_codes.add_code((code, min_version, max_version))
272 return func
273 return decorator
276class ResponseObject(object):
277 """Bundles a response object
279 Object that app methods may return in order to allow its response
280 to be modified by extensions in the code. Its use is optional (and
281 should only be used if you really know what you are doing).
282 """
284 def __init__(self, obj, code=None, headers=None):
285 """Builds a response object."""
287 self.obj = obj
288 self._default_code = 200
289 self._code = code
290 self._headers = headers or {}
291 self.serializer = JSONDictSerializer()
293 def __getitem__(self, key):
294 """Retrieves a header with the given name."""
296 return self._headers[key.lower()]
298 def __setitem__(self, key, value):
299 """Sets a header with the given name to the given value."""
301 self._headers[key.lower()] = value
303 def __delitem__(self, key):
304 """Deletes the header with the given name."""
306 del self._headers[key.lower()]
308 def serialize(self, request, content_type):
309 """Serializes the wrapped object.
311 Utility method for serializing the wrapped object. Returns a
312 webob.Response object.
314 Header values are set to the appropriate Python type and
315 encoding demanded by PEP 3333: whatever the native str type is.
316 """
318 serializer = self.serializer
320 body = None
321 if self.obj is not None:
322 body = serializer.serialize(self.obj)
323 response = webob.Response(body=body)
324 response.status_int = self.code
325 for hdr, val in self._headers.items():
326 # In Py3.X Headers must be a str that was first safely
327 # encoded to UTF-8 (to catch any bad encodings) and then
328 # decoded back to a native str.
329 response.headers[hdr] = encodeutils.safe_decode(
330 encodeutils.safe_encode(val))
331 # Deal with content_type
332 if not isinstance(content_type, str): 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true
333 content_type = str(content_type)
334 # In Py3.X Headers must be a str.
335 response.headers['Content-Type'] = encodeutils.safe_decode(
336 encodeutils.safe_encode(content_type))
337 return response
339 @property
340 def code(self):
341 """Retrieve the response status."""
343 return self._code or self._default_code
345 @property
346 def headers(self):
347 """Retrieve the headers."""
349 return self._headers.copy()
352def action_peek(body):
353 """Determine action to invoke.
355 This looks inside the json body and fetches out the action method
356 name.
357 """
359 try:
360 decoded = jsonutils.loads(body)
361 except ValueError:
362 msg = _("cannot understand JSON")
363 raise exception.MalformedRequestBody(reason=msg)
365 # Make sure there's exactly one key...
366 if len(decoded) != 1:
367 msg = _("too many body keys")
368 raise exception.MalformedRequestBody(reason=msg)
370 # Return the action name
371 return list(decoded.keys())[0]
374class ResourceExceptionHandler(object):
375 """Context manager to handle Resource exceptions.
377 Used when processing exceptions generated by API implementation
378 methods. Converts most exceptions to Fault
379 exceptions, with the appropriate logging.
380 """
382 def __enter__(self):
383 return None
385 def __exit__(self, ex_type, ex_value, ex_traceback):
386 if not ex_value:
387 return True
389 if isinstance(ex_value, exception.Forbidden):
390 raise Fault(webob.exc.HTTPForbidden(
391 explanation=ex_value.format_message()))
392 elif isinstance(ex_value, exception.VersionNotFoundForAPIMethod): 392 ↛ 393line 392 didn't jump to line 393 because the condition on line 392 was never true
393 raise
394 elif isinstance(ex_value, exception.Invalid):
395 raise Fault(exception.ConvertedException(
396 code=ex_value.code,
397 explanation=ex_value.format_message()))
398 elif isinstance(ex_value, TypeError):
399 exc_info = (ex_type, ex_value, ex_traceback)
400 LOG.error('Exception handling resource: %s', ex_value,
401 exc_info=exc_info)
402 raise Fault(webob.exc.HTTPBadRequest())
403 elif isinstance(ex_value, Fault): 403 ↛ 404line 403 didn't jump to line 404 because the condition on line 403 was never true
404 LOG.info("Fault thrown: %s", ex_value)
405 raise ex_value
406 elif isinstance(ex_value, webob.exc.HTTPException):
407 LOG.info("HTTP exception thrown: %s", ex_value)
408 raise Fault(ex_value)
410 # We didn't handle the exception
411 return False
414class Resource(wsgi.Application):
415 """WSGI app that handles (de)serialization and controller dispatch.
417 WSGI app that reads routing information supplied by RoutesMiddleware
418 and calls the requested action method upon its controller. All
419 controller action methods must accept a 'req' argument, which is the
420 incoming wsgi.Request. If the operation is a PUT or POST, the controller
421 method must also accept a 'body' argument (the deserialized request body).
422 They may raise a webob.exc exception or return a dict, which will be
423 serialized by requested content type.
425 Exceptions derived from webob.exc.HTTPException will be automatically
426 wrapped in Fault() to provide API friendly error responses.
428 """
429 support_api_request_version = True
431 def __init__(self, controller):
432 """:param controller: object that implement methods created by routes
433 lib
434 """
435 self.controller = controller
436 self.sub_controllers = []
438 self.default_serializers = dict(json=JSONDictSerializer)
440 # Copy over the actions dictionary
441 self.wsgi_actions = {}
442 if controller:
443 self.register_actions(controller)
445 def register_actions(self, controller):
446 """Registers controller actions with this resource."""
448 actions = getattr(controller, 'wsgi_actions', {})
449 for key, method_name in actions.items():
450 self.wsgi_actions[key] = getattr(controller, method_name)
452 def register_subcontroller_actions(self, sub_controller):
453 """Registers sub-controller actions with this resource."""
454 self.sub_controllers.append(sub_controller)
455 actions = getattr(sub_controller, 'wsgi_actions', {})
456 for key, method_name in actions.items():
457 self.wsgi_actions[key] = getattr(sub_controller, method_name)
459 def get_action_args(self, request_environment):
460 """Parse dictionary created by routes library."""
462 # NOTE(Vek): Check for get_action_args() override in the
463 # controller
464 if hasattr(self.controller, 'get_action_args'): 464 ↛ 465line 464 didn't jump to line 465 because the condition on line 464 was never true
465 return self.controller.get_action_args(request_environment)
467 try:
468 args = request_environment['wsgiorg.routing_args'][1].copy()
469 except (KeyError, IndexError, AttributeError):
470 return {}
472 try:
473 del args['controller']
474 except KeyError:
475 pass
477 try:
478 del args['format']
479 except KeyError:
480 pass
482 return args
484 def get_body(self, request):
485 content_type = request.get_content_type()
487 return content_type, request.body
489 def deserialize(self, body):
490 return JSONDeserializer().deserialize(body)
492 def _should_have_body(self, request):
493 return request.method in _METHODS_WITH_BODY
495 @webob.dec.wsgify(RequestClass=Request)
496 def __call__(self, request):
497 """WSGI method that controls (de)serialization and method dispatch."""
499 if self.support_api_request_version:
500 # Set the version of the API requested based on the header
501 try:
502 request.set_api_version_request()
503 except exception.InvalidAPIVersionString as e:
504 return Fault(webob.exc.HTTPBadRequest(
505 explanation=e.format_message()))
506 except exception.InvalidGlobalAPIVersion as e:
507 return Fault(webob.exc.HTTPNotAcceptable(
508 explanation=e.format_message()))
510 # Identify the action, its arguments, and the requested
511 # content type
512 action_args = self.get_action_args(request.environ)
513 action = action_args.pop('action', None)
515 # NOTE(sdague): we filter out InvalidContentTypes early so we
516 # know everything is good from here on out.
517 try:
518 content_type, body = self.get_body(request)
519 accept = request.best_match_content_type()
520 except exception.InvalidContentType:
521 msg = _("Unsupported Content-Type")
522 return Fault(webob.exc.HTTPUnsupportedMediaType(explanation=msg))
524 # NOTE(Vek): Splitting the function up this way allows for
525 # auditing by external tools that wrap the existing
526 # function. If we try to audit __call__(), we can
527 # run into troubles due to the @webob.dec.wsgify()
528 # decorator.
529 return self._process_stack(request, action, action_args,
530 content_type, body, accept)
532 def _process_stack(self, request, action, action_args,
533 content_type, body, accept):
534 """Implement the processing stack."""
536 # Get the implementing method
537 try:
538 meth = self.get_method(request, action,
539 content_type, body)
540 except (AttributeError, TypeError):
541 return Fault(webob.exc.HTTPNotFound())
542 except KeyError as ex:
543 msg = _("There is no such action: %s") % ex.args[0]
544 return Fault(webob.exc.HTTPBadRequest(explanation=msg))
545 except exception.MalformedRequestBody:
546 msg = _("Malformed request body")
547 return Fault(webob.exc.HTTPBadRequest(explanation=msg))
549 if body:
550 msg = _("Action: '%(action)s', calling method: %(meth)s, body: "
551 "%(body)s") % {'action': action,
552 'body': str(body, 'utf-8'),
553 'meth': str(meth)}
554 LOG.debug(strutils.mask_password(msg))
555 else:
556 LOG.debug("Calling method '%(meth)s'",
557 {'meth': str(meth)})
559 # Now, deserialize the request body...
560 try:
561 contents = self._get_request_content(body, request)
562 except exception.MalformedRequestBody:
563 msg = _("Malformed request body")
564 return Fault(webob.exc.HTTPBadRequest(explanation=msg))
566 # Update the action args
567 action_args.update(contents)
569 project_id = action_args.pop("project_id", None)
570 context = request.environ.get('nova.context')
571 if (context and project_id and (project_id != context.project_id)): 571 ↛ 572line 571 didn't jump to line 572 because the condition on line 571 was never true
572 msg = _("Malformed request URL: URL's project_id '%(project_id)s'"
573 " doesn't match Context's project_id"
574 " '%(context_project_id)s'") % \
575 {'project_id': project_id,
576 'context_project_id': context.project_id}
577 return Fault(webob.exc.HTTPBadRequest(explanation=msg))
579 response = None
580 try:
581 with ResourceExceptionHandler():
582 action_result = self.dispatch(meth, request, action_args)
583 except Fault as ex:
584 response = ex
586 if not response:
587 # No exceptions; convert action_result into a
588 # ResponseObject
589 resp_obj = None
590 if type(action_result) is dict or action_result is None:
591 resp_obj = ResponseObject(action_result)
592 elif isinstance(action_result, ResponseObject):
593 resp_obj = action_result
594 else:
595 response = action_result
597 # Run post-processing extensions
598 if resp_obj:
599 # Do a preserialize to set up the response object
600 if hasattr(meth, 'wsgi_codes'):
601 resp_obj._default_code = meth.wsgi_codes(request)
603 if resp_obj and not response:
604 response = resp_obj.serialize(request, accept)
606 if hasattr(response, 'headers'):
607 for hdr, val in list(response.headers.items()):
608 if not isinstance(val, str):
609 val = str(val)
610 # In Py3.X Headers must be a string
611 response.headers[hdr] = encodeutils.safe_decode(
612 encodeutils.safe_encode(val))
614 if not request.api_version_request.is_null():
615 response.headers[API_VERSION_REQUEST_HEADER] = \
616 'compute ' + request.api_version_request.get_string()
617 response.headers[LEGACY_API_VERSION_REQUEST_HEADER] = \
618 request.api_version_request.get_string()
619 response.headers.add('Vary', API_VERSION_REQUEST_HEADER)
620 response.headers.add('Vary', LEGACY_API_VERSION_REQUEST_HEADER)
622 return response
624 def _get_request_content(self, body, request):
625 contents = {}
626 if self._should_have_body(request):
627 # allow empty body with PUT and POST
628 if request.content_length == 0 or request.content_length is None:
629 contents = {'body': None}
630 else:
631 contents = self.deserialize(body)
632 return contents
634 def get_method(self, request, action, content_type, body):
635 meth = self._get_method(request,
636 action,
637 content_type,
638 body)
639 return meth
641 def _get_method(self, request, action, content_type, body):
642 """Look up the action-specific method."""
643 # Look up the method
644 try:
645 if not self.controller:
646 meth = getattr(self, action)
647 else:
648 meth = getattr(self.controller, action)
649 return meth
650 except AttributeError:
651 if (not self.wsgi_actions or
652 action not in _ROUTES_METHODS + ['action']):
653 # Propagate the error
654 raise
655 if action == 'action':
656 action_name = action_peek(body)
657 else:
658 action_name = action
660 # Look up the action method
661 return (self.wsgi_actions[action_name])
663 def dispatch(self, method, request, action_args):
664 """Dispatch a call to the action-specific method."""
666 try:
667 return method(req=request, **action_args)
668 except exception.VersionNotFoundForAPIMethod:
669 # We deliberately don't return any message information
670 # about the exception to the user so it looks as if
671 # the method is simply not implemented.
672 return Fault(webob.exc.HTTPNotFound())
675def action(name):
676 """Mark a function as an action.
678 The given name will be taken as the action key in the body.
680 This is also overloaded to allow extensions to provide
681 non-extending definitions of create and delete operations.
682 """
684 def decorator(func):
685 func.wsgi_action = name
686 return func
687 return decorator
690def removed(version: str, reason: str):
691 """Mark a function as removed.
693 The given reason will be stored as an attribute of the function.
694 """
695 def decorator(func):
696 func.removed = True
697 func.removed_version = reason
698 func.removed_reason = reason
699 return func
700 return decorator
703def expected_errors(
704 errors: ty.Union[int, tuple[int, ...]],
705 min_version: ty.Optional[str] = None,
706 max_version: ty.Optional[str] = None,
707):
708 """Decorator for v2.1 API methods which specifies expected exceptions.
710 Specify which exceptions may occur when an API method is called. If an
711 unexpected exception occurs then return a 500 instead and ask the user
712 of the API to file a bug report.
713 """
714 def decorator(f):
715 @functools.wraps(f)
716 def wrapped(*args, **kwargs):
717 min_ver = api_version.APIVersionRequest(min_version)
718 max_ver = api_version.APIVersionRequest(max_version)
720 # The request object is always the second argument.
721 # However numerous unittests pass in the request object
722 # via kwargs instead so we handle that as well.
723 # TODO(cyeoh): cleanup unittests so we don't have to
724 # to do this
725 if 'req' in kwargs:
726 ver = kwargs['req'].api_version_request
727 else:
728 ver = args[1].api_version_request
730 try:
731 return f(*args, **kwargs)
732 except Exception as exc:
733 # if this instance of the decorator is intended for other
734 # versions, let the exception bubble up as-is
735 if not ver.matches(min_ver, max_ver):
736 raise
738 if isinstance(exc, webob.exc.WSGIHTTPException):
739 if isinstance(errors, int):
740 t_errors = (errors,)
741 else:
742 t_errors = errors
743 if exc.code in t_errors:
744 raise
745 elif isinstance(exc, exception.Forbidden):
746 # Note(cyeoh): Special case to handle
747 # Forbidden exceptions so every
748 # extension method does not need to wrap authorize
749 # calls. ResourceExceptionHandler silently
750 # converts NotAuthorized to HTTPForbidden
751 raise
752 elif isinstance(exc, exception.NotSupported):
753 # Note(gmann): Special case to handle
754 # NotSupported exceptions. We want to raise 400 BadRequest
755 # for the NotSupported exception which is basically used
756 # to raise for not supported features. Converting it here
757 # will avoid converting every NotSupported inherited
758 # exception in API controller.
759 raise webob.exc.HTTPBadRequest(
760 explanation=exc.format_message())
761 elif isinstance(exc, exception.ValidationError):
762 # Note(oomichi): Handle a validation error, which
763 # happens due to invalid API parameters, as an
764 # expected error.
765 raise
766 elif isinstance(exc, exception.Unauthorized): 766 ↛ 771line 766 didn't jump to line 771 because the condition on line 766 was never true
767 # Handle an authorized exception, will be
768 # automatically converted to a HTTP 401, clients
769 # like python-novaclient handle this error to
770 # generate new token and do another attempt.
771 raise
773 LOG.exception("Unexpected exception in API method")
774 msg = _("Unexpected API Error. "
775 "{support}\n{exc}").format(
776 support=version.support_string(),
777 exc=type(exc))
778 raise webob.exc.HTTPInternalServerError(explanation=msg)
780 return wrapped
782 return decorator
785class ControllerMetaclass(type):
786 """Controller metaclass.
788 This metaclass automates the task of assembling a dictionary
789 mapping action keys to method names.
790 """
792 def __new__(mcs, name, bases, cls_dict):
793 """Adds the wsgi_actions dictionary to the class."""
795 # Find all actions
796 actions = {}
797 versioned_methods = None
798 # start with wsgi actions from base classes
799 for base in bases:
800 actions.update(getattr(base, 'wsgi_actions', {}))
802 if base.__name__ == "Controller":
803 # NOTE(cyeoh): This resets the VER_METHOD_ATTR attribute
804 # between API controller class creations. This allows us
805 # to use a class decorator on the API methods that doesn't
806 # require naming explicitly what method is being versioned as
807 # it can be implicit based on the method decorated. It is a bit
808 # ugly.
809 if VER_METHOD_ATTR in base.__dict__:
810 versioned_methods = getattr(base, VER_METHOD_ATTR)
811 delattr(base, VER_METHOD_ATTR)
813 for key, value in cls_dict.items():
814 if not callable(value):
815 continue
816 if getattr(value, 'wsgi_action', None):
817 actions[value.wsgi_action] = key
819 # Add the actions to the class dict
820 cls_dict['wsgi_actions'] = actions
821 if versioned_methods:
822 cls_dict[VER_METHOD_ATTR] = versioned_methods
824 return super(ControllerMetaclass, mcs).__new__(mcs, name, bases,
825 cls_dict)
828class Controller(metaclass=ControllerMetaclass):
829 """Default controller."""
831 _view_builder_class = None
833 def __init__(self):
834 """Initialize controller with a view builder instance."""
835 if self._view_builder_class:
836 self._view_builder = self._view_builder_class()
837 else:
838 self._view_builder = None
840 def __getattribute__(self, key):
842 def version_select(*args, **kwargs):
843 """Look for the method which matches the name supplied and version
844 constraints and calls it with the supplied arguments.
846 @return: Returns the result of the method called
847 @raises: VersionNotFoundForAPIMethod if there is no method which
848 matches the name and version constraints
849 """
851 # The first arg to all versioned methods is always the request
852 # object. The version for the request is attached to the
853 # request object
854 if len(args) == 0:
855 ver = kwargs['req'].api_version_request
856 else:
857 ver = args[0].api_version_request
859 func_list = self.versioned_methods[key]
860 for func in func_list:
861 if ver.matches(func.start_version, func.end_version):
862 # Update the version_select wrapper function so
863 # other decorator attributes like wsgi.response
864 # are still respected.
865 functools.update_wrapper(version_select, func.func)
866 return func.func(self, *args, **kwargs)
868 # No version match
869 raise exception.VersionNotFoundForAPIMethod(version=ver)
871 try:
872 version_meth_dict = object.__getattribute__(self, VER_METHOD_ATTR)
873 except AttributeError:
874 # No versioning on this class
875 return object.__getattribute__(self, key)
877 if version_meth_dict and \
878 key in object.__getattribute__(self, VER_METHOD_ATTR):
879 return version_select
881 return object.__getattribute__(self, key)
883 # NOTE(cyeoh): This decorator MUST appear first (the outermost
884 # decorator) on an API method for it to work correctly
885 @classmethod
886 def api_version(cls, min_ver, max_ver=None):
887 """Decorator for versioning api methods.
889 Add the decorator to any method which takes a request object
890 as the first parameter and belongs to a class which inherits from
891 wsgi.Controller.
893 @min_ver: string representing minimum version
894 @max_ver: optional string representing maximum version
895 """
897 def decorator(f):
898 obj_min_ver = api_version.APIVersionRequest(min_ver)
899 if max_ver:
900 obj_max_ver = api_version.APIVersionRequest(max_ver)
901 else:
902 obj_max_ver = api_version.APIVersionRequest()
904 # Add to list of versioned methods registered
905 func_name = f.__name__
906 new_func = versioned_method.VersionedMethod(
907 func_name, obj_min_ver, obj_max_ver, f)
909 func_dict = getattr(cls, VER_METHOD_ATTR, {})
910 if not func_dict:
911 setattr(cls, VER_METHOD_ATTR, func_dict)
913 func_list = func_dict.get(func_name, [])
914 if not func_list:
915 func_dict[func_name] = func_list
916 func_list.append(new_func)
917 # Ensure the list is sorted by minimum version (reversed)
918 # so later when we work through the list in order we find
919 # the method which has the latest version which supports
920 # the version requested.
921 is_intersect = Controller.check_for_versions_intersection(
922 func_list)
924 if is_intersect: 924 ↛ 925line 924 didn't jump to line 925 because the condition on line 924 was never true
925 raise exception.ApiVersionsIntersect(
926 name=new_func.name,
927 min_ver=new_func.start_version,
928 max_ver=new_func.end_version,
929 )
931 func_list.sort(key=lambda f: f.start_version, reverse=True)
933 return f
935 return decorator
937 @staticmethod
938 def is_valid_body(body, entity_name):
939 if not (body and entity_name in body):
940 return False
942 def is_dict(d):
943 try:
944 d.get(None)
945 return True
946 except AttributeError:
947 return False
949 return is_dict(body[entity_name])
951 @staticmethod
952 def check_for_versions_intersection(func_list):
953 """Determines whether function list contains version intervals
954 intersections or not. General algorithm:
956 https://en.wikipedia.org/wiki/Intersection_algorithm
958 :param func_list: list of VersionedMethod objects
959 :return: boolean
960 """
961 pairs = []
962 counter = 0
964 for f in func_list:
965 pairs.append((f.start_version, 1, f))
966 pairs.append((f.end_version, -1, f))
968 def compare(x):
969 return x[0]
971 pairs.sort(key=compare)
973 for p in pairs:
974 counter += p[1]
976 if counter > 1:
977 return True
979 return False
982class Fault(webob.exc.HTTPException):
983 """Wrap webob.exc.HTTPException to provide API friendly response."""
985 _fault_names = {
986 400: "badRequest",
987 401: "unauthorized",
988 403: "forbidden",
989 404: "itemNotFound",
990 405: "badMethod",
991 409: "conflictingRequest",
992 413: "overLimit",
993 415: "badMediaType",
994 429: "overLimit",
995 501: "notImplemented",
996 503: "serviceUnavailable"}
998 def __init__(self, exception):
999 """Create a Fault for the given webob.exc.exception."""
1000 self.wrapped_exc = exception
1001 for key, value in list(self.wrapped_exc.headers.items()):
1002 self.wrapped_exc.headers[key] = str(value)
1003 self.status_int = exception.status_int
1005 @webob.dec.wsgify(RequestClass=Request)
1006 def __call__(self, req):
1007 """Generate a WSGI response based on the exception passed to ctor."""
1009 user_locale = req.best_match_language()
1010 # Replace the body with fault details.
1011 code = self.wrapped_exc.status_int
1012 fault_name = self._fault_names.get(code, "computeFault")
1013 explanation = self.wrapped_exc.explanation
1014 LOG.debug("Returning %(code)s to user: %(explanation)s",
1015 {'code': code, 'explanation': explanation})
1017 explanation = i18n.translate(explanation, user_locale)
1018 fault_data = {
1019 fault_name: {
1020 'code': code,
1021 'message': explanation}}
1022 if code == 413 or code == 429:
1023 retry = self.wrapped_exc.headers.get('Retry-After', None)
1024 if retry:
1025 fault_data[fault_name]['retryAfter'] = retry
1027 if not req.api_version_request.is_null():
1028 self.wrapped_exc.headers[API_VERSION_REQUEST_HEADER] = \
1029 'compute ' + req.api_version_request.get_string()
1030 self.wrapped_exc.headers[LEGACY_API_VERSION_REQUEST_HEADER] = \
1031 req.api_version_request.get_string()
1032 self.wrapped_exc.headers.add('Vary', API_VERSION_REQUEST_HEADER)
1033 self.wrapped_exc.headers.add('Vary',
1034 LEGACY_API_VERSION_REQUEST_HEADER)
1036 self.wrapped_exc.content_type = 'application/json'
1037 self.wrapped_exc.charset = 'UTF-8'
1038 self.wrapped_exc.text = JSONDictSerializer().serialize(fault_data)
1040 return self.wrapped_exc
1042 def __str__(self):
1043 return self.wrapped_exc.__str__()