Coverage for manila/api/openstack/wsgi.py: 92%
662 statements
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
1# Copyright 2011 OpenStack LLC.
2# All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
16import functools
17from http import client as http_client
18import inspect
19import math
20import time
22from oslo_log import log
23from oslo_serialization import jsonutils
24from oslo_utils import encodeutils
25from oslo_utils import strutils
26import webob
27import webob.exc
29from manila.api.openstack import api_version_request as api_version
30from manila.api.openstack import versioned_method
31from manila.common import constants
32from manila import exception
33from manila.i18n import _
34from manila import policy
35from manila import utils
36from manila.wsgi import common as wsgi
38LOG = log.getLogger(__name__)
40SUPPORTED_CONTENT_TYPES = (
41 'application/json',
42)
44_MEDIA_TYPE_MAP = {
45 'application/json': 'json',
46}
48# name of attribute to keep version method information
49VER_METHOD_ATTR = 'versioned_methods'
51# Name of header used by clients to request a specific version
52# of the REST API
53API_VERSION_REQUEST_HEADER = 'X-OpenStack-Manila-API-Version'
54EXPERIMENTAL_API_REQUEST_HEADER = 'X-OpenStack-Manila-API-Experimental'
56V1_SCRIPT_NAME = '/v1'
57V2_SCRIPT_NAME = '/v2'
60class Request(webob.Request):
61 """Add some OpenStack API-specific logic to the base webob.Request."""
63 def __init__(self, *args, **kwargs):
64 super(Request, self).__init__(*args, **kwargs)
65 self._resource_cache = {}
66 if not hasattr(self, 'api_version_request'):
67 self.api_version_request = api_version.APIVersionRequest()
69 def cache_resource(self, resource_to_cache, id_attribute='id', name=None):
70 """Cache the given resource.
72 Allow API methods to cache objects, such as results from a DB query,
73 to be used by API extensions within the same API request.
75 The resource_to_cache can be a list or an individual resource,
76 but ultimately resources are cached individually using the given
77 id_attribute.
79 Different resources types might need to be cached during the same
80 request, they can be cached using the name parameter. For example:
82 Controller 1:
83 request.cache_resource(db_volumes, 'volumes')
84 request.cache_resource(db_volume_types, 'types')
85 Controller 2:
86 db_volumes = request.cached_resource('volumes')
87 db_type_1 = request.cached_resource_by_id('1', 'types')
89 If no name is given, a default name will be used for the resource.
91 An instance of this class only lives for the lifetime of a
92 single API request, so there's no need to implement full
93 cache management.
94 """
95 if not isinstance(resource_to_cache, list):
96 resource_to_cache = [resource_to_cache]
97 if not name:
98 name = self.path
99 cached_resources = self._resource_cache.setdefault(name, {})
100 for resource in resource_to_cache:
101 cached_resources[resource[id_attribute]] = resource
103 def cached_resource(self, name=None):
104 """Get the cached resources cached under the given resource name.
106 Allow an API extension to get previously stored objects within
107 the same API request.
109 Note that the object data will be slightly stale.
111 :returns: a dict of id_attribute to the resource from the cached
112 resources, an empty map if an empty collection was cached,
113 or None if nothing has been cached yet under this name
114 """
115 if not name:
116 name = self.path
117 if name not in self._resource_cache:
118 # Nothing has been cached for this key yet
119 return None
120 return self._resource_cache[name]
122 def cached_resource_by_id(self, resource_id, name=None):
123 """Get a resource by ID cached under the given resource name.
125 Allow an API extension to get a previously stored object
126 within the same API request. This is basically a convenience method
127 to lookup by ID on the dictionary of all cached resources.
129 Note that the object data will be slightly stale.
131 :returns: the cached resource or None if the item is not in the cache
132 """
133 resources = self.cached_resource(name)
134 if not resources:
135 # Nothing has been cached yet for this key yet
136 return None
137 return resources.get(resource_id)
139 def cache_db_items(self, key, items, item_key='id'):
140 """Cache db items.
142 Allow API methods to store objects from a DB query to be
143 used by API extensions within the same API request.
144 An instance of this class only lives for the lifetime of a
145 single API request, so there's no need to implement full
146 cache management.
147 """
148 self.cache_resource(items, item_key, key)
150 def get_db_items(self, key):
151 """Get db item by key.
153 Allow an API extension to get previously stored objects within
154 the same API request.
155 Note that the object data will be slightly stale.
156 """
157 return self.cached_resource(key)
159 def get_db_item(self, key, item_key):
160 """Get db item by key and item key.
162 Allow an API extension to get a previously stored object
163 within the same API request.
164 Note that the object data will be slightly stale.
165 """
166 return self.get_db_items(key).get(item_key)
168 def cache_db_share_types(self, share_types):
169 self.cache_db_items('share_types', share_types, 'id')
171 def cache_db_share_type(self, share_type):
172 self.cache_db_items('share_types', [share_type], 'id')
174 def get_db_share_types(self):
175 return self.get_db_items('share_types')
177 def get_db_share_type(self, share_type_id):
178 return self.get_db_item('share_types', share_type_id)
180 def best_match_content_type(self):
181 """Determine the requested response content-type."""
182 if 'manila.best_content_type' not in self.environ:
183 # Calculate the best MIME type
184 content_type = None
186 # Check URL path suffix
187 parts = self.path.rsplit('.', 1)
188 if len(parts) > 1:
189 possible_type = 'application/' + parts[1]
190 if possible_type in SUPPORTED_CONTENT_TYPES:
191 content_type = possible_type
193 if not content_type:
194 content_type = self.accept.best_match(SUPPORTED_CONTENT_TYPES)
196 self.environ['manila.best_content_type'] = (content_type or
197 'application/json')
199 return self.environ['manila.best_content_type']
201 def get_content_type(self):
202 """Determine content type of the request body.
204 Does not do any body introspection, only checks header.
205 """
206 if "Content-Type" not in self.headers:
207 return None
209 allowed_types = SUPPORTED_CONTENT_TYPES
210 content_type = self.content_type
212 if content_type not in allowed_types:
213 raise exception.InvalidContentType(content_type=content_type)
215 return content_type
217 def set_api_version_request(self):
218 """Set API version request based on the request header information.
220 Microversions starts with /v2, so if a client sends a /v1 URL, then
221 ignore the headers and request 1.0 APIs.
222 """
223 if not self.script_name or not (V1_SCRIPT_NAME in self.script_name or
224 V2_SCRIPT_NAME in self.script_name):
225 # The request is on the base URL without a major version specified
226 self.api_version_request = api_version.APIVersionRequest()
227 elif V1_SCRIPT_NAME in self.script_name:
228 self.api_version_request = api_version.APIVersionRequest('1.0')
229 else:
230 if API_VERSION_REQUEST_HEADER in self.headers:
231 hdr_string = self.headers[API_VERSION_REQUEST_HEADER]
232 self.api_version_request = api_version.APIVersionRequest(
233 hdr_string)
235 # Check that the version requested is within the global
236 # minimum/maximum of supported API versions
237 if not self.api_version_request.matches(
238 api_version.min_api_version(),
239 api_version.max_api_version()):
240 raise exception.InvalidGlobalAPIVersion(
241 req_ver=self.api_version_request.get_string(),
242 min_ver=api_version.min_api_version().get_string(),
243 max_ver=api_version.max_api_version().get_string())
245 else:
246 self.api_version_request = api_version.APIVersionRequest(
247 api_version.DEFAULT_API_VERSION)
249 # Check if experimental API was requested
250 if EXPERIMENTAL_API_REQUEST_HEADER in self.headers:
251 self.api_version_request.experimental = strutils.bool_from_string(
252 self.headers[EXPERIMENTAL_API_REQUEST_HEADER])
255class ActionDispatcher(object):
256 """Maps method name to local methods through action name."""
258 def dispatch(self, *args, **kwargs):
259 """Find and call local method."""
260 action = kwargs.pop('action', 'default')
261 action_method = getattr(self, str(action), self.default)
262 return action_method(*args, **kwargs)
264 def default(self, data):
265 raise NotImplementedError()
268class TextDeserializer(ActionDispatcher):
269 """Default request body deserialization."""
271 def deserialize(self, datastring, action='default'):
272 return self.dispatch(datastring, action=action)
274 def default(self, datastring):
275 return {}
278class JSONDeserializer(TextDeserializer):
280 def _from_json(self, datastring):
281 try:
282 return jsonutils.loads(datastring)
283 except ValueError:
284 msg = _("cannot understand JSON")
285 raise exception.MalformedRequestBody(reason=msg)
287 def default(self, datastring):
288 return {'body': self._from_json(datastring)}
291class DictSerializer(ActionDispatcher):
292 """Default request body serialization."""
294 def serialize(self, data, action='default'):
295 return self.dispatch(data, action=action)
297 def default(self, data):
298 return ""
301class JSONDictSerializer(DictSerializer):
302 """Default JSON request body serialization."""
304 def default(self, data):
305 return jsonutils.dump_as_bytes(data)
308def serializers(**serializers):
309 """Attaches serializers to a method.
311 This decorator associates a dictionary of serializers with a
312 method. Note that the function attributes are directly
313 manipulated; the method is not wrapped.
314 """
316 def decorator(func):
317 if not hasattr(func, 'wsgi_serializers'):
318 func.wsgi_serializers = {}
319 func.wsgi_serializers.update(serializers)
320 return func
321 return decorator
324def deserializers(**deserializers):
325 """Attaches deserializers to a method.
327 This decorator associates a dictionary of deserializers with a
328 method. Note that the function attributes are directly
329 manipulated; the method is not wrapped.
330 """
332 def decorator(func):
333 if not hasattr(func, 'wsgi_deserializers'): 333 ↛ 335line 333 didn't jump to line 335 because the condition on line 333 was always true
334 func.wsgi_deserializers = {}
335 func.wsgi_deserializers.update(deserializers)
336 return func
337 return decorator
340def response(code):
341 """Attaches response code to a method.
343 This decorator associates a response code with a method. Note
344 that the function attributes are directly manipulated; the method
345 is not wrapped.
346 """
348 def decorator(func):
349 func.wsgi_code = code
350 return func
351 return decorator
354class ResponseObject(object):
355 """Bundles a response object with appropriate serializers.
357 Object that app methods may return in order to bind alternate
358 serializers with a response object to be serialized. Its use is
359 optional.
360 """
362 def __init__(self, obj, code=None, headers=None, **serializers):
363 """Binds serializers with an object.
365 Takes keyword arguments akin to the @serializer() decorator
366 for specifying serializers. Serializers specified will be
367 given preference over default serializers or method-specific
368 serializers on return.
369 """
371 self.obj = obj
372 self.serializers = serializers
373 self._default_code = 200
374 self._code = code
375 self._headers = headers or {}
376 self.serializer = None
377 self.media_type = None
379 def __getitem__(self, key):
380 """Retrieves a header with the given name."""
382 return self._headers[key.lower()]
384 def __setitem__(self, key, value):
385 """Sets a header with the given name to the given value."""
387 self._headers[key.lower()] = value
389 def __delitem__(self, key):
390 """Deletes the header with the given name."""
392 del self._headers[key.lower()]
394 def _bind_method_serializers(self, meth_serializers):
395 """Binds method serializers with the response object.
397 Binds the method serializers with the response object.
398 Serializers specified to the constructor will take precedence
399 over serializers specified to this method.
401 :param meth_serializers: A dictionary with keys mapping to
402 response types and values containing
403 serializer objects.
404 """
406 # We can't use update because that would be the wrong
407 # precedence
408 for mtype, serializer in meth_serializers.items():
409 self.serializers.setdefault(mtype, serializer)
411 def get_serializer(self, content_type, default_serializers=None):
412 """Returns the serializer for the wrapped object.
414 Returns the serializer for the wrapped object subject to the
415 indicated content type. If no serializer matching the content
416 type is attached, an appropriate serializer drawn from the
417 default serializers will be used. If no appropriate
418 serializer is available, raises InvalidContentType.
419 """
421 default_serializers = default_serializers or {}
423 try:
424 mtype = _MEDIA_TYPE_MAP.get(content_type, content_type)
425 if mtype in self.serializers:
426 return mtype, self.serializers[mtype]
427 else:
428 return mtype, default_serializers[mtype]
429 except (KeyError, TypeError):
430 raise exception.InvalidContentType(content_type=content_type)
432 def preserialize(self, content_type, default_serializers=None):
433 """Prepares the serializer that will be used to serialize.
435 Determines the serializer that will be used and prepares an
436 instance of it for later call. This allows the serializer to
437 be accessed by extensions for, e.g., template extension.
438 """
440 mtype, serializer = self.get_serializer(content_type,
441 default_serializers)
442 self.media_type = mtype
443 self.serializer = serializer()
445 def attach(self, **kwargs):
446 """Attach slave templates to serializers."""
448 if self.media_type in kwargs:
449 self.serializer.attach(kwargs[self.media_type])
451 def serialize(self, request, content_type, default_serializers=None):
452 """Serializes the wrapped object.
454 Utility method for serializing the wrapped object. Returns a
455 webob.Response object.
456 """
458 if self.serializer:
459 serializer = self.serializer
460 else:
461 _mtype, _serializer = self.get_serializer(content_type,
462 default_serializers)
463 serializer = _serializer()
465 response = webob.Response()
466 response.status_int = self.code
467 for hdr, value in self._headers.items():
468 response.headers[hdr] = str(value)
469 response.headers['Content-Type'] = str(content_type)
470 if self.obj is not None:
471 response.body = serializer.serialize(self.obj)
473 return response
475 @property
476 def code(self):
477 """Retrieve the response status."""
479 return self._code or self._default_code
481 @property
482 def headers(self):
483 """Retrieve the headers."""
485 return self._headers.copy()
488def action_peek_json(body):
489 """Determine action to invoke."""
491 try:
492 decoded = jsonutils.loads(body)
493 except ValueError:
494 msg = _("cannot understand JSON")
495 raise exception.MalformedRequestBody(reason=msg)
497 # Make sure there's exactly one key...
498 if len(decoded) != 1:
499 msg = _("too many body keys")
500 raise exception.MalformedRequestBody(reason=msg)
502 # Return the action and the decoded body...
503 return list(decoded.keys())[0]
506class ResourceExceptionHandler(object):
507 """Context manager to handle Resource exceptions.
509 Used when processing exceptions generated by API implementation
510 methods (or their extensions). Converts most exceptions to Fault
511 exceptions, with the appropriate logging.
512 """
514 def __enter__(self):
515 return None
517 def __exit__(self, ex_type, ex_value, ex_traceback):
518 if not ex_value:
519 return True
521 msg = str(ex_value)
522 if isinstance(ex_value, exception.NotAuthorized):
523 raise Fault(webob.exc.HTTPForbidden(explanation=msg))
524 elif isinstance(ex_value, exception.VersionNotFoundForAPIMethod):
525 raise
526 elif isinstance(ex_value, exception.Invalid):
527 raise Fault(exception.ConvertedException(
528 code=ex_value.code, explanation=msg))
529 elif isinstance(ex_value, TypeError): 529 ↛ 530line 529 didn't jump to line 530 because the condition on line 529 was never true
530 exc_info = (ex_type, ex_value, ex_traceback)
531 LOG.error('Exception handling resource: %s',
532 ex_value, exc_info=exc_info)
533 raise Fault(webob.exc.HTTPBadRequest())
534 elif isinstance(ex_value, Fault): 534 ↛ 535line 534 didn't jump to line 535 because the condition on line 534 was never true
535 LOG.info("Fault thrown: %s", ex_value)
536 raise ex_value
537 elif isinstance(ex_value, webob.exc.HTTPException):
538 LOG.info("HTTP exception thrown: %s", ex_value)
539 raise Fault(ex_value)
541 # We didn't handle the exception
542 return False
545class Resource(wsgi.Application):
546 """WSGI app that handles (de)serialization and controller dispatch.
548 WSGI app that reads routing information supplied by RoutesMiddleware
549 and calls the requested action method upon its controller. All
550 controller action methods must accept a 'req' argument, which is the
551 incoming wsgi.Request. If the operation is a PUT or POST, the controller
552 method must also accept a 'body' argument (the deserialized request body).
553 They may raise a webob.exc exception or return a dict, which will be
554 serialized by requested content type.
556 Exceptions derived from webob.exc.HTTPException will be automatically
557 wrapped in Fault() to provide API friendly error responses.
558 """
559 support_api_request_version = True
561 def __init__(self, controller, action_peek=None, **deserializers):
562 """init method of Resource.
564 :param controller: object that implement methods created by routes lib
565 :param action_peek: dictionary of routines for peeking into an action
566 request body to determine the desired action
567 """
569 self.controller = controller
571 default_deserializers = dict(json=JSONDeserializer)
572 default_deserializers.update(deserializers)
574 self.default_deserializers = default_deserializers
575 self.default_serializers = dict(json=JSONDictSerializer)
577 self.action_peek = dict(json=action_peek_json)
578 self.action_peek.update(action_peek or {})
580 # Copy over the actions dictionary
581 self.wsgi_actions = {}
582 if controller:
583 self.register_actions(controller)
585 # Save a mapping of extensions
586 self.wsgi_extensions = {}
587 self.wsgi_action_extensions = {}
589 def register_actions(self, controller):
590 """Registers controller actions with this resource."""
592 actions = getattr(controller, 'wsgi_actions', {})
593 for key, method_name in actions.items():
594 self.wsgi_actions[key] = getattr(controller, method_name)
596 def register_extensions(self, controller):
597 """Registers controller extensions with this resource."""
599 extensions = getattr(controller, 'wsgi_extensions', [])
600 for method_name, action_name in extensions:
601 # Look up the extending method
602 extension = getattr(controller, method_name)
604 if action_name:
605 # Extending an action...
606 if action_name not in self.wsgi_action_extensions: 606 ↛ 608line 606 didn't jump to line 608 because the condition on line 606 was always true
607 self.wsgi_action_extensions[action_name] = []
608 self.wsgi_action_extensions[action_name].append(extension)
609 else:
610 # Extending a regular method
611 if method_name not in self.wsgi_extensions: 611 ↛ 613line 611 didn't jump to line 613 because the condition on line 611 was always true
612 self.wsgi_extensions[method_name] = []
613 self.wsgi_extensions[method_name].append(extension)
615 def get_action_args(self, request_environment):
616 """Parse dictionary created by routes library."""
618 # NOTE(Vek): Check for get_action_args() override in the
619 # controller
620 if hasattr(self.controller, 'get_action_args'):
621 return self.controller.get_action_args(request_environment)
623 try:
624 args = request_environment['wsgiorg.routing_args'][1].copy()
625 except (KeyError, IndexError, AttributeError):
626 return {}
628 try:
629 del args['controller']
630 except KeyError:
631 pass
633 try:
634 del args['format']
635 except KeyError:
636 pass
638 return args
640 def get_body(self, request):
641 try:
642 content_type = request.get_content_type()
643 except exception.InvalidContentType:
644 LOG.debug("Unrecognized Content-Type provided in request")
645 return None, ''
647 if not content_type:
648 LOG.debug("No Content-Type provided in request")
649 return None, ''
651 if len(request.body) <= 0:
652 LOG.debug("Empty body provided in request")
653 return None, ''
655 return content_type, request.body
657 def deserialize(self, meth, content_type, body):
658 meth_deserializers = getattr(meth, 'wsgi_deserializers', {})
659 try:
660 mtype = _MEDIA_TYPE_MAP.get(content_type, content_type)
661 if mtype in meth_deserializers: 661 ↛ 662line 661 didn't jump to line 662 because the condition on line 661 was never true
662 deserializer = meth_deserializers[mtype]
663 else:
664 deserializer = self.default_deserializers[mtype]
665 except (KeyError, TypeError):
666 raise exception.InvalidContentType(content_type=content_type)
668 return deserializer().deserialize(body)
670 def pre_process_extensions(self, extensions, request, action_args):
671 # List of callables for post-processing extensions
672 post = []
674 for ext in extensions:
675 if inspect.isgeneratorfunction(ext):
676 response = None
678 # If it's a generator function, the part before the
679 # yield is the preprocessing stage
680 try:
681 with ResourceExceptionHandler():
682 gen = ext(req=request, **action_args)
683 response = next(gen)
684 except Fault as ex:
685 response = ex
687 # We had a response...
688 if response:
689 return response, []
691 # No response, queue up generator for post-processing
692 post.append(gen)
693 else:
694 # Regular functions only perform post-processing
695 post.append(ext)
697 # Run post-processing in the reverse order
698 return None, reversed(post)
700 def post_process_extensions(self, extensions, resp_obj, request,
701 action_args):
702 for ext in extensions:
703 response = None
704 if inspect.isgenerator(ext):
705 # If it's a generator, run the second half of
706 # processing
707 try:
708 with ResourceExceptionHandler():
709 response = ext.send(resp_obj)
710 except StopIteration:
711 # Normal exit of generator
712 continue
713 except Fault as ex:
714 response = ex
715 else:
716 # Regular functions get post-processing...
717 try:
718 with ResourceExceptionHandler():
719 response = ext(req=request, resp_obj=resp_obj,
720 **action_args)
721 except exception.VersionNotFoundForAPIMethod:
722 # If an attached extension (@wsgi.extends) for the
723 # method has no version match its not an error. We
724 # just don't run the extends code
725 continue
726 except Fault as ex:
727 response = ex
729 # We had a response...
730 if response:
731 return response
733 return None
735 @webob.dec.wsgify(RequestClass=Request)
736 def __call__(self, request):
737 """WSGI method that controls (de)serialization and method dispatch."""
739 LOG.info("%(method)s %(url)s", {"method": request.method,
740 "url": request.url})
741 if self.support_api_request_version: 741 ↛ 754line 741 didn't jump to line 754 because the condition on line 741 was always true
742 # Set the version of the API requested based on the header
743 try:
744 request.set_api_version_request()
745 except exception.InvalidAPIVersionString as e:
746 return Fault(webob.exc.HTTPBadRequest(
747 explanation=e.msg))
748 except exception.InvalidGlobalAPIVersion as e:
749 return Fault(webob.exc.HTTPNotAcceptable(
750 explanation=e.msg))
752 # Identify the action, its arguments, and the requested
753 # content type
754 action_args = self.get_action_args(request.environ)
755 action = action_args.pop('action', None)
756 content_type, body = self.get_body(request)
757 accept = request.best_match_content_type()
759 # NOTE(Vek): Splitting the function up this way allows for
760 # auditing by external tools that wrap the existing
761 # function. If we try to audit __call__(), we can
762 # run into troubles due to the @webob.dec.wsgify()
763 # decorator.
764 return self._process_stack(request, action, action_args,
765 content_type, body, accept)
767 def _process_stack(self, request, action, action_args,
768 content_type, body, accept):
769 """Implement the processing stack."""
771 # Get the implementing method
772 try:
773 meth, extensions = self.get_method(request, action,
774 content_type, body)
775 except (AttributeError, TypeError):
776 return Fault(webob.exc.HTTPNotFound())
777 except KeyError as ex:
778 msg = _("There is no such action: %s") % ex.args[0]
779 return Fault(webob.exc.HTTPBadRequest(explanation=msg))
780 except exception.MalformedRequestBody:
781 msg = _("Malformed request body")
782 return Fault(webob.exc.HTTPBadRequest(explanation=msg))
784 try:
785 method_name = meth.__qualname__
786 except AttributeError:
787 method_name = 'Controller: %s Method: %s' % (
788 str(self.controller), meth.__name__)
790 if body:
791 decoded_body = encodeutils.safe_decode(body, errors='ignore')
792 msg = ("Action: '%(action)s', calling method: %(meth)s, body: "
793 "%(body)s") % {'action': action,
794 'body': decoded_body,
795 'meth': method_name}
796 LOG.debug(strutils.mask_password(msg))
797 else:
798 LOG.debug("Calling method '%(meth)s'", {'meth': method_name})
800 # Now, deserialize the request body...
801 try:
802 if content_type:
803 contents = self.deserialize(meth, content_type, body)
804 else:
805 contents = {}
806 except exception.InvalidContentType:
807 msg = _("Unsupported Content-Type")
808 return Fault(webob.exc.HTTPBadRequest(explanation=msg))
809 except exception.MalformedRequestBody:
810 msg = _("Malformed request body")
811 return Fault(webob.exc.HTTPBadRequest(explanation=msg))
813 # Update the action args
814 action_args.update(contents)
816 project_id = action_args.pop("project_id", None)
817 context = request.environ.get('manila.context')
818 if (context and project_id and (project_id != context.project_id)): 818 ↛ 819line 818 didn't jump to line 819 because the condition on line 818 was never true
819 msg = _("Malformed request url")
820 return Fault(webob.exc.HTTPBadRequest(explanation=msg))
822 # Run pre-processing extensions
823 response, post = self.pre_process_extensions(extensions,
824 request, action_args)
826 if not response: 826 ↛ 833line 826 didn't jump to line 833 because the condition on line 826 was always true
827 try:
828 with ResourceExceptionHandler():
829 action_result = self.dispatch(meth, request, action_args)
830 except Fault as ex:
831 response = ex
833 if not response:
834 # No exceptions; convert action_result into a
835 # ResponseObject
836 resp_obj = None
837 if type(action_result) is dict or action_result is None:
838 resp_obj = ResponseObject(action_result)
839 elif isinstance(action_result, ResponseObject): 839 ↛ 840line 839 didn't jump to line 840 because the condition on line 839 was never true
840 resp_obj = action_result
841 else:
842 response = action_result
844 # Run post-processing extensions
845 if resp_obj:
846 _set_request_id_header(request, resp_obj)
847 # Do a preserialize to set up the response object
848 serializers = getattr(meth, 'wsgi_serializers', {})
849 resp_obj._bind_method_serializers(serializers)
850 if hasattr(meth, 'wsgi_code'):
851 resp_obj._default_code = meth.wsgi_code
852 resp_obj.preserialize(accept, self.default_serializers)
854 # Process post-processing extensions
855 response = self.post_process_extensions(post, resp_obj,
856 request, action_args)
858 if resp_obj and not response:
859 response = resp_obj.serialize(request, accept,
860 self.default_serializers)
862 try:
863 msg_dict = dict(url=request.url, status=response.status_int)
864 msg = _("%(url)s returned with HTTP %(status)s") % msg_dict
865 except AttributeError as e:
866 msg_dict = dict(url=request.url, e=e)
867 msg = _("%(url)s returned a fault: %(e)s") % msg_dict
869 LOG.info(msg)
871 if hasattr(response, 'headers'):
872 for hdr, val in response.headers.items():
873 val = utils.convert_str(val)
874 response.headers[hdr] = val
875 _set_request_id_header(request, response.headers)
876 if not request.api_version_request.is_null():
877 response.headers[API_VERSION_REQUEST_HEADER] = (
878 request.api_version_request.get_string())
879 if request.api_version_request.experimental:
880 # NOTE(vponomaryov): Translate our boolean header
881 # to string explicitly to avoid 'TypeError' failure
882 # running manila API under Apache + mod-wsgi.
883 # It is safe to do so, because all headers are returned as
884 # strings anyway.
885 response.headers[EXPERIMENTAL_API_REQUEST_HEADER] = (
886 '%s' % request.api_version_request.experimental)
887 response.headers['Vary'] = API_VERSION_REQUEST_HEADER
889 return response
891 def get_method(self, request, action, content_type, body):
892 """Look up the action-specific method and its extensions."""
894 # Look up the method
895 try:
896 if not self.controller: 896 ↛ 897line 896 didn't jump to line 897 because the condition on line 896 was never true
897 meth = getattr(self, action)
898 else:
899 meth = getattr(self.controller, action)
900 except AttributeError:
901 if (not self.wsgi_actions or
902 action not in ['action', 'create', 'delete']):
903 # Propagate the error
904 raise
905 else:
906 return meth, self.wsgi_extensions.get(action, [])
908 if action == 'action':
909 # OK, it's an action; figure out which action...
910 mtype = _MEDIA_TYPE_MAP.get(content_type)
911 action_name = self.action_peek[mtype](body)
912 LOG.debug("Action body: %s", body)
913 else:
914 action_name = action
916 # Look up the action method
917 return (self.wsgi_actions[action_name],
918 self.wsgi_action_extensions.get(action_name, []))
920 def dispatch(self, method, request, action_args):
921 """Dispatch a call to the action-specific method."""
923 try:
924 return method(req=request, **action_args)
925 except exception.VersionNotFoundForAPIMethod:
926 # We deliberately don't return any message information
927 # about the exception to the user so it looks as if
928 # the method is simply not implemented.
929 return Fault(webob.exc.HTTPNotFound())
932def action(name):
933 """Mark a function as an action.
935 The given name will be taken as the action key in the body.
937 This is also overloaded to allow extensions to provide
938 non-extending definitions of create and delete operations.
939 """
941 def decorator(func):
942 func.wsgi_action = name
943 return func
944 return decorator
947def extends(*args, **kwargs):
948 """Indicate a function extends an operation.
950 Can be used as either::
952 @extends
953 def index(...):
954 pass
956 or as::
958 @extends(action='resize')
959 def _action_resize(...):
960 pass
961 """
963 def decorator(func):
964 # Store enough information to find what we're extending
965 func.wsgi_extends = (func.__name__, kwargs.get('action'))
966 return func
968 # If we have positional arguments, call the decorator
969 if args:
970 return decorator(*args)
972 # OK, return the decorator instead
973 return decorator
976class ControllerMetaclass(type):
977 """Controller metaclass.
979 This metaclass automates the task of assembling a dictionary
980 mapping action keys to method names.
981 """
983 def __new__(mcs, name, bases, cls_dict):
984 """Adds the wsgi_actions dictionary to the class."""
986 # Find all actions
987 actions = {}
988 extensions = []
989 versioned_methods = None
990 # start with wsgi actions from base classes
991 for base in bases:
992 actions.update(getattr(base, 'wsgi_actions', {}))
994 if base.__name__ == "Controller":
995 # NOTE(cyeoh): This resets the VER_METHOD_ATTR attribute
996 # between API controller class creations. This allows us
997 # to use a class decorator on the API methods that doesn't
998 # require naming explicitly what method is being versioned as
999 # it can be implicit based on the method decorated. It is a bit
1000 # ugly.
1001 if VER_METHOD_ATTR in base.__dict__:
1002 versioned_methods = getattr(base, VER_METHOD_ATTR)
1003 delattr(base, VER_METHOD_ATTR)
1005 for key, value in cls_dict.items():
1006 if not callable(value):
1007 continue
1008 if getattr(value, 'wsgi_action', None):
1009 actions[value.wsgi_action] = key
1010 elif getattr(value, 'wsgi_extends', None):
1011 extensions.append(value.wsgi_extends)
1013 # Add the actions and extensions to the class dict
1014 cls_dict['wsgi_actions'] = actions
1015 cls_dict['wsgi_extensions'] = extensions
1016 if versioned_methods:
1017 cls_dict[VER_METHOD_ATTR] = versioned_methods
1019 return super(ControllerMetaclass, mcs).__new__(mcs, name, bases,
1020 cls_dict)
1023class Controller(metaclass=ControllerMetaclass):
1024 """Default controller."""
1026 _view_builder_class = None
1028 def __init__(self, view_builder=None):
1029 """Initialize controller with a view builder instance."""
1030 if view_builder: 1030 ↛ 1031line 1030 didn't jump to line 1031 because the condition on line 1030 was never true
1031 self._view_builder = view_builder
1032 elif self._view_builder_class:
1033 # pylint: disable=not-callable
1034 self._view_builder = self._view_builder_class()
1035 else:
1036 self._view_builder = None
1038 def __getattribute__(self, key):
1040 def version_select(*args, **kwargs):
1041 """Select and call the matching version of the specified method.
1043 Look for the method which matches the name supplied and version
1044 constraints and calls it with the supplied arguments.
1046 :returns: Returns the result of the method called
1047 :raises: VersionNotFoundForAPIMethod if there is no method which
1048 matches the name and version constraints
1049 """
1051 # The first arg to all versioned methods is always the request
1052 # object. The version for the request is attached to the
1053 # request object
1054 if len(args) == 0:
1055 version_request = kwargs['req'].api_version_request
1056 else:
1057 version_request = args[0].api_version_request
1059 func_list = self.versioned_methods[key]
1060 for func in func_list:
1061 if version_request.matches_versioned_method(func):
1062 # Update the version_select wrapper function so
1063 # other decorator attributes like wsgi.response
1064 # are still respected.
1065 functools.update_wrapper(version_select, func.func)
1066 return func.func(self, *args, **kwargs)
1068 # No version match
1069 raise exception.VersionNotFoundForAPIMethod(
1070 version=version_request)
1072 try:
1073 version_meth_dict = object.__getattribute__(self, VER_METHOD_ATTR)
1074 except AttributeError:
1075 # No versioning on this class
1076 return object.__getattribute__(self, key)
1078 if (version_meth_dict and
1079 key in object.__getattribute__(self, VER_METHOD_ATTR)):
1080 return version_select
1082 return object.__getattribute__(self, key)
1084 # NOTE(cyeoh): This decorator MUST appear first (the outermost
1085 # decorator) on an API method for it to work correctly
1086 @classmethod
1087 def api_version(cls, min_ver, max_ver=None, experimental=False):
1088 """Decorator for versioning API methods.
1090 Add the decorator to any method which takes a request object
1091 as the first parameter and belongs to a class which inherits from
1092 wsgi.Controller.
1094 :param min_ver: string representing minimum version
1095 :param max_ver: optional string representing maximum version
1096 :param experimental: flag indicating an API is experimental and is
1097 subject to change or removal at any time
1098 """
1100 def decorator(f):
1101 obj_min_ver = api_version.APIVersionRequest(min_ver)
1102 if max_ver:
1103 obj_max_ver = api_version.APIVersionRequest(max_ver)
1104 else:
1105 obj_max_ver = api_version.APIVersionRequest()
1107 # Add to list of versioned methods registered
1108 func_name = f.__name__
1109 new_func = versioned_method.VersionedMethod(
1110 func_name, obj_min_ver, obj_max_ver, experimental, f)
1112 func_dict = getattr(cls, VER_METHOD_ATTR, {})
1113 if not func_dict:
1114 setattr(cls, VER_METHOD_ATTR, func_dict)
1116 func_list = func_dict.get(func_name, [])
1117 if not func_list:
1118 func_dict[func_name] = func_list
1119 func_list.append(new_func)
1120 # Ensure the list is sorted by minimum version (reversed)
1121 # so later when we work through the list in order we find
1122 # the method which has the latest version which supports
1123 # the version requested.
1124 # TODO(cyeoh): Add check to ensure that there are no overlapping
1125 # ranges of valid versions as that is ambiguous
1126 func_list.sort(reverse=True)
1128 return f
1130 return decorator
1132 @staticmethod
1133 def authorize(arg):
1134 """Decorator for checking the policy on API methods.
1136 Add this decorator to any API method which takes a request object
1137 as the first parameter and belongs to a class which inherits from
1138 wsgi.Controller. The class must also have a class member called
1139 'resource_name' which specifies the resource for the policy check.
1141 Can be used in any of the following forms
1142 @authorize
1143 @authorize('my_action_name')
1145 :param arg: Can either be the function being decorated or a str
1146 containing the 'action' for the policy check. If no action name is
1147 provided, the function name is assumed to be the action name.
1148 """
1149 action_name = None
1151 def decorator(f):
1152 @functools.wraps(f)
1153 def wrapper(self, req, *args, **kwargs):
1154 action = action_name or f.__name__
1155 context = req.environ['manila.context']
1156 try:
1157 policy.check_policy(context, self.resource_name, action)
1158 except exception.PolicyNotAuthorized:
1159 raise webob.exc.HTTPForbidden()
1160 return f(self, req, *args, **kwargs)
1161 return wrapper
1163 if callable(arg):
1164 return decorator(arg)
1165 else:
1166 action_name = arg
1167 return decorator
1169 @staticmethod
1170 def is_valid_body(body, entity_name):
1171 if not (body and entity_name in body):
1172 return False
1174 def is_dict(d):
1175 try:
1176 d.get(None)
1177 return True
1178 except AttributeError:
1179 return False
1181 if not is_dict(body[entity_name]):
1182 return False
1184 return True
1187class AdminActionsMixin(object):
1188 """Mixin class for API controllers with admin actions."""
1190 body_attributes = {
1191 'status': 'reset_status',
1192 'replica_state': 'reset_replica_state',
1193 'task_state': 'reset_task_state',
1194 }
1196 valid_statuses = {
1197 'status': set([
1198 constants.STATUS_CREATING,
1199 constants.STATUS_AVAILABLE,
1200 constants.STATUS_DELETING,
1201 constants.STATUS_ERROR,
1202 constants.STATUS_ERROR_DELETING,
1203 constants.STATUS_MIGRATING,
1204 constants.STATUS_MIGRATING_TO,
1205 constants.STATUS_SERVER_MIGRATING,
1206 ]),
1207 'replica_state': set([
1208 constants.REPLICA_STATE_ACTIVE,
1209 constants.REPLICA_STATE_IN_SYNC,
1210 constants.REPLICA_STATE_OUT_OF_SYNC,
1211 constants.STATUS_ERROR,
1212 ]),
1213 'task_state': set(constants.TASK_STATE_STATUSES),
1214 }
1216 def _update(self, *args, **kwargs):
1217 raise NotImplementedError()
1219 def _get(self, *args, **kwargs):
1220 raise NotImplementedError()
1222 def _delete(self, *args, **kwargs):
1223 raise NotImplementedError()
1225 def validate_update(self, body, status_attr='status'):
1226 update = {}
1227 try:
1228 update[status_attr] = body[status_attr]
1229 except (TypeError, KeyError):
1230 msg = _("Must specify '%s'") % status_attr
1231 raise webob.exc.HTTPBadRequest(explanation=msg)
1232 if update[status_attr] not in self.valid_statuses[status_attr]:
1233 expl = (_("Invalid state. Valid states: %s.") %
1234 ", ".join(str(i) for i in
1235 self.valid_statuses[status_attr]))
1236 raise webob.exc.HTTPBadRequest(explanation=expl)
1237 return update
1239 @Controller.authorize('reset_status')
1240 def _reset_status(self, req, id, body, status_attr='status',
1241 resource=None):
1242 """Reset the status_attr specified on the resource.
1244 :param req: API request object
1245 :param id: ID of the resource
1246 :param body: API request body
1247 :param status_attr: Attribute on the resource denoting the status
1248 to be reset
1249 :param resource: Resource model or dict if we need to avoid fetching it
1250 """
1251 context = req.environ['manila.context']
1252 body_attr = self.body_attributes[status_attr]
1253 update = self.validate_update(
1254 body.get(body_attr, body.get('-'.join(('os', body_attr)))),
1255 status_attr=status_attr)
1256 msg = "Updating %(resource)s '%(id)s' with '%(update)r'"
1257 LOG.debug(msg, {'resource': self.resource_name, 'id': id,
1258 'update': update})
1259 try:
1260 resource = resource or self._get(context, id)
1261 except exception.NotFound as e:
1262 raise webob.exc.HTTPNotFound(e.message)
1264 if (status_attr == 'replica_state' and
1265 resource.get('replica_state') ==
1266 constants.REPLICA_STATE_ACTIVE):
1267 msg = _("Cannot reset replica_state of an active replica")
1268 raise webob.exc.HTTPBadRequest(explanation=msg)
1269 try:
1270 policy.check_policy(context,
1271 self.resource_name,
1272 "reset_status",
1273 target_obj=resource)
1274 except exception.NotAuthorized as e:
1275 raise webob.exc.HTTPForbidden(e.message)
1276 self._update(context, id, update)
1277 return webob.Response(status_int=http_client.ACCEPTED)
1279 @Controller.authorize('force_delete')
1280 def _force_delete(self, req, id, body):
1281 """Delete a resource, bypassing the check for status."""
1282 context = req.environ['manila.context']
1283 try:
1284 resource = self._get(context, id)
1285 except exception.NotFound as e:
1286 raise webob.exc.HTTPNotFound(e.message)
1287 policy.check_policy(context,
1288 self.resource_name,
1289 "force_delete",
1290 target_obj=resource)
1291 self._delete(context, resource, force=True)
1292 return webob.Response(status_int=http_client.ACCEPTED)
1295class Fault(webob.exc.HTTPException):
1296 """Wrap webob.exc.HTTPException to provide API friendly response."""
1298 _fault_names = {400: "badRequest",
1299 401: "unauthorized",
1300 403: "forbidden",
1301 404: "itemNotFound",
1302 405: "badMethod",
1303 409: "conflictingRequest",
1304 413: "overLimit",
1305 415: "badMediaType",
1306 501: "notImplemented",
1307 503: "serviceUnavailable"}
1309 def __init__(self, exception):
1310 """Create a Fault for the given webob.exc.exception."""
1311 self.wrapped_exc = exception
1312 self.status_int = exception.status_int
1314 @webob.dec.wsgify(RequestClass=Request)
1315 def __call__(self, req):
1316 """Generate a WSGI response based on the exception passed to ctor."""
1317 # Replace the body with fault details.
1318 code = self.wrapped_exc.status_int
1319 fault_name = self._fault_names.get(code, "computeFault")
1320 fault_data = {
1321 fault_name: {
1322 'code': code,
1323 'message': self.wrapped_exc.explanation}}
1324 if code == 413:
1325 retry = self.wrapped_exc.headers['Retry-After']
1326 fault_data[fault_name]['retryAfter'] = '%s' % retry
1328 if not req.api_version_request.is_null():
1329 self.wrapped_exc.headers[API_VERSION_REQUEST_HEADER] = (
1330 req.api_version_request.get_string())
1331 if req.api_version_request.experimental:
1332 # NOTE(vponomaryov): Translate our boolean header
1333 # to string explicitly to avoid 'TypeError' failure
1334 # running manila API under Apache + mod-wsgi.
1335 # It is safe to do so, because all headers are returned as
1336 # strings anyway.
1337 self.wrapped_exc.headers[EXPERIMENTAL_API_REQUEST_HEADER] = (
1338 '%s' % req.api_version_request.experimental)
1339 self.wrapped_exc.headers['Vary'] = API_VERSION_REQUEST_HEADER
1341 content_type = req.best_match_content_type()
1342 serializer = {
1343 'application/json': JSONDictSerializer(),
1344 }[content_type]
1346 self.wrapped_exc.body = serializer.serialize(fault_data)
1347 self.wrapped_exc.content_type = content_type
1348 _set_request_id_header(req, self.wrapped_exc.headers)
1350 return self.wrapped_exc
1352 def __str__(self):
1353 return self.wrapped_exc.__str__()
1356def _set_request_id_header(req, headers):
1357 context = req.environ.get('manila.context')
1358 if context:
1359 headers['x-compute-request-id'] = context.request_id
1362class OverLimitFault(webob.exc.HTTPException):
1363 """Rate-limited request response."""
1365 def __init__(self, message, details, retry_time):
1366 """Initialize new `OverLimitFault` with relevant information."""
1367 hdrs = OverLimitFault._retry_after(retry_time)
1368 self.wrapped_exc = webob.exc.HTTPRequestEntityTooLarge(headers=hdrs)
1369 self.content = {
1370 "overLimitFault": {
1371 "code": self.wrapped_exc.status_int,
1372 "message": message,
1373 "details": details,
1374 },
1375 }
1377 @staticmethod
1378 def _retry_after(retry_time):
1379 delay = int(math.ceil(retry_time - time.time()))
1380 retry_after = delay if delay > 0 else 0
1381 headers = {'Retry-After': '%s' % retry_after}
1382 return headers
1384 @webob.dec.wsgify(RequestClass=Request)
1385 def __call__(self, request):
1386 """Wrap the exception.
1388 Wrap the exception with a serialized body conforming to our
1389 error format.
1390 """
1391 content_type = request.best_match_content_type()
1393 serializer = {
1394 'application/json': JSONDictSerializer(),
1395 }[content_type]
1397 content = serializer.serialize(self.content)
1398 self.wrapped_exc.body = content
1400 return self.wrapped_exc