1262 lines
43 KiB
Python
1262 lines
43 KiB
Python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
|
|
|
# Copyright 2011 OpenStack LLC.
|
|
# All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
# not use this file except in compliance with the License. You may obtain
|
|
# a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
# License for the specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
import inspect
|
|
import math
|
|
import time
|
|
from xml.dom import minidom
|
|
from xml.parsers import expat
|
|
|
|
from lxml import etree
|
|
import webob
|
|
|
|
from nova import exception
|
|
from nova.openstack.common import jsonutils
|
|
from nova.openstack.common import log as logging
|
|
from nova import wsgi
|
|
|
|
|
|
XMLNS_V10 = 'http://docs.rackspacecloud.com/servers/api/v1.0'
|
|
XMLNS_V11 = 'http://docs.openstack.org/compute/api/v1.1'
|
|
|
|
XMLNS_ATOM = 'http://www.w3.org/2005/Atom'
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
# The vendor content types should serialize identically to the non-vendor
|
|
# content types. So to avoid littering the code with both options, we
|
|
# map the vendor to the other when looking up the type
|
|
_CONTENT_TYPE_MAP = {
|
|
'application/vnd.openstack.compute+json': 'application/json',
|
|
'application/vnd.openstack.compute+xml': 'application/xml',
|
|
}
|
|
|
|
SUPPORTED_CONTENT_TYPES = (
|
|
'application/json',
|
|
'application/vnd.openstack.compute+json',
|
|
'application/xml',
|
|
'application/vnd.openstack.compute+xml',
|
|
)
|
|
|
|
_MEDIA_TYPE_MAP = {
|
|
'application/vnd.openstack.compute+json': 'json',
|
|
'application/json': 'json',
|
|
'application/vnd.openstack.compute+xml': 'xml',
|
|
'application/xml': 'xml',
|
|
'application/atom+xml': 'atom',
|
|
}
|
|
|
|
|
|
class Request(webob.Request):
|
|
"""Add some OpenStack API-specific logic to the base webob.Request."""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(Request, self).__init__(*args, **kwargs)
|
|
self._extension_data = {'db_items': {}}
|
|
|
|
def cache_db_items(self, key, items, item_key='id'):
|
|
"""
|
|
Allow API methods to store objects from a DB query to be
|
|
used by API extensions within the same API request.
|
|
|
|
An instance of this class only lives for the lifetime of a
|
|
single API request, so there's no need to implement full
|
|
cache management.
|
|
"""
|
|
db_items = self._extension_data['db_items'].setdefault(key, {})
|
|
for item in items:
|
|
db_items[item[item_key]] = item
|
|
|
|
def get_db_items(self, key):
|
|
"""
|
|
Allow an API extension to get previously stored objects within
|
|
the same API request.
|
|
|
|
Note that the object data will be slightly stale.
|
|
"""
|
|
return self._extension_data['db_items'][key]
|
|
|
|
def get_db_item(self, key, item_key):
|
|
"""
|
|
Allow an API extension to get a previously stored object
|
|
within the same API request.
|
|
|
|
Note that the object data will be slightly stale.
|
|
"""
|
|
return self.get_db_items(key).get(item_key)
|
|
|
|
def cache_db_instances(self, instances):
|
|
self.cache_db_items('instances', instances, 'uuid')
|
|
|
|
def cache_db_instance(self, instance):
|
|
self.cache_db_items('instances', [instance], 'uuid')
|
|
|
|
def get_db_instances(self):
|
|
return self.get_db_items('instances')
|
|
|
|
def get_db_instance(self, instance_uuid):
|
|
return self.get_db_item('instances', instance_uuid)
|
|
|
|
def cache_db_flavors(self, flavors):
|
|
self.cache_db_items('flavors', flavors, 'flavorid')
|
|
|
|
def cache_db_flavor(self, flavor):
|
|
self.cache_db_items('flavors', [flavor], 'flavorid')
|
|
|
|
def get_db_flavors(self):
|
|
return self.get_db_items('flavors')
|
|
|
|
def get_db_flavor(self, flavorid):
|
|
return self.get_db_item('flavors', flavorid)
|
|
|
|
def best_match_content_type(self):
|
|
"""Determine the requested response content-type."""
|
|
if 'nova.best_content_type' not in self.environ:
|
|
# Calculate the best MIME type
|
|
content_type = None
|
|
|
|
# Check URL path suffix
|
|
parts = self.path.rsplit('.', 1)
|
|
if len(parts) > 1:
|
|
possible_type = 'application/' + parts[1]
|
|
if possible_type in SUPPORTED_CONTENT_TYPES:
|
|
content_type = possible_type
|
|
|
|
if not content_type:
|
|
content_type = self.accept.best_match(SUPPORTED_CONTENT_TYPES)
|
|
|
|
self.environ['nova.best_content_type'] = (content_type or
|
|
'application/json')
|
|
|
|
return self.environ['nova.best_content_type']
|
|
|
|
def get_content_type(self):
|
|
"""Determine content type of the request body.
|
|
|
|
Does not do any body introspection, only checks header
|
|
|
|
"""
|
|
if not "Content-Type" in self.headers:
|
|
return None
|
|
|
|
content_type = self.content_type
|
|
|
|
# NOTE(markmc): text/plain is the default for eventlet and
|
|
# other webservers which use mimetools.Message.gettype()
|
|
# whereas twisted defaults to ''.
|
|
if not content_type or content_type == 'text/plain':
|
|
return None
|
|
|
|
if content_type not in SUPPORTED_CONTENT_TYPES:
|
|
raise exception.InvalidContentType(content_type=content_type)
|
|
|
|
return content_type
|
|
|
|
|
|
class ActionDispatcher(object):
|
|
"""Maps method name to local methods through action name."""
|
|
|
|
def dispatch(self, *args, **kwargs):
|
|
"""Find and call local method."""
|
|
action = kwargs.pop('action', 'default')
|
|
action_method = getattr(self, str(action), self.default)
|
|
return action_method(*args, **kwargs)
|
|
|
|
def default(self, data):
|
|
raise NotImplementedError()
|
|
|
|
|
|
class TextDeserializer(ActionDispatcher):
|
|
"""Default request body deserialization."""
|
|
|
|
def deserialize(self, datastring, action='default'):
|
|
return self.dispatch(datastring, action=action)
|
|
|
|
def default(self, datastring):
|
|
return {}
|
|
|
|
|
|
class JSONDeserializer(TextDeserializer):
|
|
|
|
def _from_json(self, datastring):
|
|
try:
|
|
return jsonutils.loads(datastring)
|
|
except ValueError:
|
|
msg = _("cannot understand JSON")
|
|
raise exception.MalformedRequestBody(reason=msg)
|
|
|
|
def default(self, datastring):
|
|
return {'body': self._from_json(datastring)}
|
|
|
|
|
|
class XMLDeserializer(TextDeserializer):
|
|
|
|
def __init__(self, metadata=None):
|
|
"""
|
|
:param metadata: information needed to deserialize xml into
|
|
a dictionary.
|
|
"""
|
|
super(XMLDeserializer, self).__init__()
|
|
self.metadata = metadata or {}
|
|
|
|
def _from_xml(self, datastring):
|
|
plurals = set(self.metadata.get('plurals', {}))
|
|
|
|
try:
|
|
node = minidom.parseString(datastring).childNodes[0]
|
|
return {node.nodeName: self._from_xml_node(node, plurals)}
|
|
except expat.ExpatError:
|
|
msg = _("cannot understand XML")
|
|
raise exception.MalformedRequestBody(reason=msg)
|
|
|
|
def _from_xml_node(self, node, listnames):
|
|
"""Convert a minidom node to a simple Python type.
|
|
|
|
:param listnames: list of XML node names whose subnodes should
|
|
be considered list items.
|
|
|
|
"""
|
|
if len(node.childNodes) == 1 and node.childNodes[0].nodeType == 3:
|
|
return node.childNodes[0].nodeValue
|
|
elif node.nodeName in listnames:
|
|
return [self._from_xml_node(n, listnames) for n in node.childNodes]
|
|
else:
|
|
result = dict()
|
|
for attr in node.attributes.keys():
|
|
result[attr] = node.attributes[attr].nodeValue
|
|
for child in node.childNodes:
|
|
if child.nodeType != node.TEXT_NODE:
|
|
result[child.nodeName] = self._from_xml_node(child,
|
|
listnames)
|
|
return result
|
|
|
|
def find_first_child_named_in_namespace(self, parent, namespace, name):
|
|
"""Search a nodes children for the first child with a given name."""
|
|
for node in parent.childNodes:
|
|
if (node.localName == name and
|
|
node.namespaceURI and
|
|
node.namespaceURI == namespace):
|
|
return node
|
|
return None
|
|
|
|
def find_first_child_named(self, parent, name):
|
|
"""Search a nodes children for the first child with a given name."""
|
|
for node in parent.childNodes:
|
|
if node.localName == name:
|
|
return node
|
|
return None
|
|
|
|
def find_children_named(self, parent, name):
|
|
"""Return all of a nodes children who have the given name."""
|
|
for node in parent.childNodes:
|
|
if node.localName == name:
|
|
yield node
|
|
|
|
def extract_text(self, node):
|
|
"""Get the text field contained by the given node."""
|
|
if len(node.childNodes) == 1:
|
|
child = node.childNodes[0]
|
|
if child.nodeType == child.TEXT_NODE:
|
|
return child.nodeValue
|
|
return ""
|
|
|
|
def extract_elements(self, node):
|
|
"""Get only Element type childs from node."""
|
|
elements = []
|
|
for child in node.childNodes:
|
|
if child.nodeType == child.ELEMENT_NODE:
|
|
elements.append(child)
|
|
return elements
|
|
|
|
def find_attribute_or_element(self, parent, name):
|
|
"""Get an attribute value; fallback to an element if not found."""
|
|
if parent.hasAttribute(name):
|
|
return parent.getAttribute(name)
|
|
|
|
node = self.find_first_child_named(parent, name)
|
|
if node:
|
|
return self.extract_text(node)
|
|
|
|
return None
|
|
|
|
def default(self, datastring):
|
|
return {'body': self._from_xml(datastring)}
|
|
|
|
|
|
class MetadataXMLDeserializer(XMLDeserializer):
|
|
|
|
def extract_metadata(self, metadata_node):
|
|
"""Marshal the metadata attribute of a parsed request."""
|
|
metadata = {}
|
|
if metadata_node is not None:
|
|
for meta_node in self.find_children_named(metadata_node, "meta"):
|
|
key = meta_node.getAttribute("key")
|
|
metadata[key] = self.extract_text(meta_node)
|
|
return metadata
|
|
|
|
|
|
class DictSerializer(ActionDispatcher):
|
|
"""Default request body serialization."""
|
|
|
|
def serialize(self, data, action='default'):
|
|
return self.dispatch(data, action=action)
|
|
|
|
def default(self, data):
|
|
return ""
|
|
|
|
|
|
class JSONDictSerializer(DictSerializer):
|
|
"""Default JSON request body serialization."""
|
|
|
|
def default(self, data):
|
|
return jsonutils.dumps(data)
|
|
|
|
|
|
class XMLDictSerializer(DictSerializer):
|
|
|
|
def __init__(self, metadata=None, xmlns=None):
|
|
"""
|
|
:param metadata: information needed to deserialize xml into
|
|
a dictionary.
|
|
:param xmlns: XML namespace to include with serialized xml
|
|
"""
|
|
super(XMLDictSerializer, self).__init__()
|
|
self.metadata = metadata or {}
|
|
self.xmlns = xmlns
|
|
|
|
def default(self, data):
|
|
# We expect data to contain a single key which is the XML root.
|
|
root_key = data.keys()[0]
|
|
doc = minidom.Document()
|
|
node = self._to_xml_node(doc, self.metadata, root_key, data[root_key])
|
|
|
|
return self.to_xml_string(node)
|
|
|
|
def to_xml_string(self, node, has_atom=False):
|
|
self._add_xmlns(node, has_atom)
|
|
return node.toxml('UTF-8')
|
|
|
|
#NOTE (ameade): the has_atom should be removed after all of the
|
|
# xml serializers and view builders have been updated to the current
|
|
# spec that required all responses include the xmlns:atom, the has_atom
|
|
# flag is to prevent current tests from breaking
|
|
def _add_xmlns(self, node, has_atom=False):
|
|
if self.xmlns is not None:
|
|
node.setAttribute('xmlns', self.xmlns)
|
|
if has_atom:
|
|
node.setAttribute('xmlns:atom', "http://www.w3.org/2005/Atom")
|
|
|
|
def _to_xml_node(self, doc, metadata, nodename, data):
|
|
"""Recursive method to convert data members to XML nodes."""
|
|
result = doc.createElement(nodename)
|
|
|
|
# Set the xml namespace if one is specified
|
|
# TODO(justinsb): We could also use prefixes on the keys
|
|
xmlns = metadata.get('xmlns', None)
|
|
if xmlns:
|
|
result.setAttribute('xmlns', xmlns)
|
|
|
|
#TODO(bcwaldon): accomplish this without a type-check
|
|
if isinstance(data, list):
|
|
collections = metadata.get('list_collections', {})
|
|
if nodename in collections:
|
|
metadata = collections[nodename]
|
|
for item in data:
|
|
node = doc.createElement(metadata['item_name'])
|
|
node.setAttribute(metadata['item_key'], str(item))
|
|
result.appendChild(node)
|
|
return result
|
|
singular = metadata.get('plurals', {}).get(nodename, None)
|
|
if singular is None:
|
|
if nodename.endswith('s'):
|
|
singular = nodename[:-1]
|
|
else:
|
|
singular = 'item'
|
|
for item in data:
|
|
node = self._to_xml_node(doc, metadata, singular, item)
|
|
result.appendChild(node)
|
|
#TODO(bcwaldon): accomplish this without a type-check
|
|
elif isinstance(data, dict):
|
|
collections = metadata.get('dict_collections', {})
|
|
if nodename in collections:
|
|
metadata = collections[nodename]
|
|
for k, v in data.items():
|
|
node = doc.createElement(metadata['item_name'])
|
|
node.setAttribute(metadata['item_key'], str(k))
|
|
text = doc.createTextNode(str(v))
|
|
node.appendChild(text)
|
|
result.appendChild(node)
|
|
return result
|
|
attrs = metadata.get('attributes', {}).get(nodename, {})
|
|
for k, v in data.items():
|
|
if k in attrs:
|
|
result.setAttribute(k, str(v))
|
|
else:
|
|
node = self._to_xml_node(doc, metadata, k, v)
|
|
result.appendChild(node)
|
|
else:
|
|
# Type is atom
|
|
node = doc.createTextNode(str(data))
|
|
result.appendChild(node)
|
|
return result
|
|
|
|
def _create_link_nodes(self, xml_doc, links):
|
|
link_nodes = []
|
|
for link in links:
|
|
link_node = xml_doc.createElement('atom:link')
|
|
link_node.setAttribute('rel', link['rel'])
|
|
link_node.setAttribute('href', link['href'])
|
|
if 'type' in link:
|
|
link_node.setAttribute('type', link['type'])
|
|
link_nodes.append(link_node)
|
|
return link_nodes
|
|
|
|
def _to_xml(self, root):
|
|
"""Convert the xml object to an xml string."""
|
|
return etree.tostring(root, encoding='UTF-8', xml_declaration=True)
|
|
|
|
|
|
def serializers(**serializers):
|
|
"""Attaches serializers to a method.
|
|
|
|
This decorator associates a dictionary of serializers with a
|
|
method. Note that the function attributes are directly
|
|
manipulated; the method is not wrapped.
|
|
"""
|
|
|
|
def decorator(func):
|
|
if not hasattr(func, 'wsgi_serializers'):
|
|
func.wsgi_serializers = {}
|
|
func.wsgi_serializers.update(serializers)
|
|
return func
|
|
return decorator
|
|
|
|
|
|
def deserializers(**deserializers):
|
|
"""Attaches deserializers to a method.
|
|
|
|
This decorator associates a dictionary of deserializers with a
|
|
method. Note that the function attributes are directly
|
|
manipulated; the method is not wrapped.
|
|
"""
|
|
|
|
def decorator(func):
|
|
if not hasattr(func, 'wsgi_deserializers'):
|
|
func.wsgi_deserializers = {}
|
|
func.wsgi_deserializers.update(deserializers)
|
|
return func
|
|
return decorator
|
|
|
|
|
|
def response(code):
|
|
"""Attaches response code to a method.
|
|
|
|
This decorator associates a response code with a method. Note
|
|
that the function attributes are directly manipulated; the method
|
|
is not wrapped.
|
|
"""
|
|
|
|
def decorator(func):
|
|
func.wsgi_code = code
|
|
return func
|
|
return decorator
|
|
|
|
|
|
class ResponseObject(object):
|
|
"""Bundles a response object with appropriate serializers.
|
|
|
|
Object that app methods may return in order to bind alternate
|
|
serializers with a response object to be serialized. Its use is
|
|
optional.
|
|
"""
|
|
|
|
def __init__(self, obj, code=None, headers=None, **serializers):
|
|
"""Binds serializers with an object.
|
|
|
|
Takes keyword arguments akin to the @serializer() decorator
|
|
for specifying serializers. Serializers specified will be
|
|
given preference over default serializers or method-specific
|
|
serializers on return.
|
|
"""
|
|
|
|
self.obj = obj
|
|
self.serializers = serializers
|
|
self._default_code = 200
|
|
self._code = code
|
|
self._headers = headers or {}
|
|
self.serializer = None
|
|
self.media_type = None
|
|
|
|
def __getitem__(self, key):
|
|
"""Retrieves a header with the given name."""
|
|
|
|
return self._headers[key.lower()]
|
|
|
|
def __setitem__(self, key, value):
|
|
"""Sets a header with the given name to the given value."""
|
|
|
|
self._headers[key.lower()] = value
|
|
|
|
def __delitem__(self, key):
|
|
"""Deletes the header with the given name."""
|
|
|
|
del self._headers[key.lower()]
|
|
|
|
def _bind_method_serializers(self, meth_serializers):
|
|
"""Binds method serializers with the response object.
|
|
|
|
Binds the method serializers with the response object.
|
|
Serializers specified to the constructor will take precedence
|
|
over serializers specified to this method.
|
|
|
|
:param meth_serializers: A dictionary with keys mapping to
|
|
response types and values containing
|
|
serializer objects.
|
|
"""
|
|
|
|
# We can't use update because that would be the wrong
|
|
# precedence
|
|
for mtype, serializer in meth_serializers.items():
|
|
self.serializers.setdefault(mtype, serializer)
|
|
|
|
def get_serializer(self, content_type, default_serializers=None):
|
|
"""Returns the serializer for the wrapped object.
|
|
|
|
Returns the serializer for the wrapped object subject to the
|
|
indicated content type. If no serializer matching the content
|
|
type is attached, an appropriate serializer drawn from the
|
|
default serializers will be used. If no appropriate
|
|
serializer is available, raises InvalidContentType.
|
|
"""
|
|
|
|
default_serializers = default_serializers or {}
|
|
|
|
try:
|
|
mtype = _MEDIA_TYPE_MAP.get(content_type, content_type)
|
|
if mtype in self.serializers:
|
|
return mtype, self.serializers[mtype]
|
|
else:
|
|
return mtype, default_serializers[mtype]
|
|
except (KeyError, TypeError):
|
|
raise exception.InvalidContentType(content_type=content_type)
|
|
|
|
def preserialize(self, content_type, default_serializers=None):
|
|
"""Prepares the serializer that will be used to serialize.
|
|
|
|
Determines the serializer that will be used and prepares an
|
|
instance of it for later call. This allows the serializer to
|
|
be accessed by extensions for, e.g., template extension.
|
|
"""
|
|
|
|
mtype, serializer = self.get_serializer(content_type,
|
|
default_serializers)
|
|
self.media_type = mtype
|
|
self.serializer = serializer()
|
|
|
|
def attach(self, **kwargs):
|
|
"""Attach slave templates to serializers."""
|
|
|
|
if self.media_type in kwargs:
|
|
self.serializer.attach(kwargs[self.media_type])
|
|
|
|
def serialize(self, request, content_type, default_serializers=None):
|
|
"""Serializes the wrapped object.
|
|
|
|
Utility method for serializing the wrapped object. Returns a
|
|
webob.Response object.
|
|
"""
|
|
|
|
if self.serializer:
|
|
serializer = self.serializer
|
|
else:
|
|
_mtype, _serializer = self.get_serializer(content_type,
|
|
default_serializers)
|
|
serializer = _serializer()
|
|
|
|
response = webob.Response()
|
|
response.status_int = self.code
|
|
for hdr, value in self._headers.items():
|
|
response.headers[hdr] = value
|
|
response.headers['Content-Type'] = content_type
|
|
if self.obj is not None:
|
|
response.body = serializer.serialize(self.obj)
|
|
|
|
return response
|
|
|
|
@property
|
|
def code(self):
|
|
"""Retrieve the response status."""
|
|
|
|
return self._code or self._default_code
|
|
|
|
@property
|
|
def headers(self):
|
|
"""Retrieve the headers."""
|
|
|
|
return self._headers.copy()
|
|
|
|
|
|
def action_peek_json(body):
|
|
"""Determine action to invoke."""
|
|
|
|
try:
|
|
decoded = jsonutils.loads(body)
|
|
except ValueError:
|
|
msg = _("cannot understand JSON")
|
|
raise exception.MalformedRequestBody(reason=msg)
|
|
|
|
# Make sure there's exactly one key...
|
|
if len(decoded) != 1:
|
|
msg = _("too many body keys")
|
|
raise exception.MalformedRequestBody(reason=msg)
|
|
|
|
# Return the action and the decoded body...
|
|
return decoded.keys()[0]
|
|
|
|
|
|
def action_peek_xml(body):
|
|
"""Determine action to invoke."""
|
|
|
|
dom = minidom.parseString(body)
|
|
action_node = dom.childNodes[0]
|
|
|
|
return action_node.tagName
|
|
|
|
|
|
class ResourceExceptionHandler(object):
|
|
"""Context manager to handle Resource exceptions.
|
|
|
|
Used when processing exceptions generated by API implementation
|
|
methods (or their extensions). Converts most exceptions to Fault
|
|
exceptions, with the appropriate logging.
|
|
"""
|
|
|
|
def __enter__(self):
|
|
return None
|
|
|
|
def __exit__(self, ex_type, ex_value, ex_traceback):
|
|
if not ex_value:
|
|
return True
|
|
|
|
if isinstance(ex_value, exception.NotAuthorized):
|
|
msg = unicode(ex_value)
|
|
raise Fault(webob.exc.HTTPForbidden(explanation=msg))
|
|
elif isinstance(ex_value, exception.Invalid):
|
|
raise Fault(exception.ConvertedException(
|
|
code=ex_value.code, explanation=unicode(ex_value)))
|
|
|
|
# Under python 2.6, TypeError's exception value is actually a string,
|
|
# so test # here via ex_type instead:
|
|
# http://bugs.python.org/issue7853
|
|
elif issubclass(ex_type, TypeError):
|
|
exc_info = (ex_type, ex_value, ex_traceback)
|
|
LOG.error(_('Exception handling resource: %s') % ex_value,
|
|
exc_info=exc_info)
|
|
raise Fault(webob.exc.HTTPBadRequest())
|
|
elif isinstance(ex_value, Fault):
|
|
LOG.info(_("Fault thrown: %s"), unicode(ex_value))
|
|
raise ex_value
|
|
elif isinstance(ex_value, webob.exc.HTTPException):
|
|
LOG.info(_("HTTP exception thrown: %s"), unicode(ex_value))
|
|
raise Fault(ex_value)
|
|
|
|
# We didn't handle the exception
|
|
return False
|
|
|
|
|
|
class Resource(wsgi.Application):
|
|
"""WSGI app that handles (de)serialization and controller dispatch.
|
|
|
|
WSGI app that reads routing information supplied by RoutesMiddleware
|
|
and calls the requested action method upon its controller. All
|
|
controller action methods must accept a 'req' argument, which is the
|
|
incoming wsgi.Request. If the operation is a PUT or POST, the controller
|
|
method must also accept a 'body' argument (the deserialized request body).
|
|
They may raise a webob.exc exception or return a dict, which will be
|
|
serialized by requested content type.
|
|
|
|
Exceptions derived from webob.exc.HTTPException will be automatically
|
|
wrapped in Fault() to provide API friendly error responses.
|
|
|
|
"""
|
|
|
|
def __init__(self, controller, action_peek=None, inherits=None,
|
|
**deserializers):
|
|
"""
|
|
:param controller: object that implement methods created by routes lib
|
|
:param action_peek: dictionary of routines for peeking into an action
|
|
request body to determine the desired action
|
|
:param inherits: another resource object that this resource should
|
|
inherit extensions from. Any action extensions that
|
|
are applied to the parent resource will also apply
|
|
to this resource.
|
|
"""
|
|
|
|
self.controller = controller
|
|
|
|
default_deserializers = dict(xml=XMLDeserializer,
|
|
json=JSONDeserializer)
|
|
default_deserializers.update(deserializers)
|
|
|
|
self.default_deserializers = default_deserializers
|
|
self.default_serializers = dict(xml=XMLDictSerializer,
|
|
json=JSONDictSerializer)
|
|
|
|
self.action_peek = dict(xml=action_peek_xml,
|
|
json=action_peek_json)
|
|
self.action_peek.update(action_peek or {})
|
|
|
|
# Copy over the actions dictionary
|
|
self.wsgi_actions = {}
|
|
if controller:
|
|
self.register_actions(controller)
|
|
|
|
# Save a mapping of extensions
|
|
self.wsgi_extensions = {}
|
|
self.wsgi_action_extensions = {}
|
|
self.inherits = inherits
|
|
|
|
def register_actions(self, controller):
|
|
"""Registers controller actions with this resource."""
|
|
|
|
actions = getattr(controller, 'wsgi_actions', {})
|
|
for key, method_name in actions.items():
|
|
self.wsgi_actions[key] = getattr(controller, method_name)
|
|
|
|
def register_extensions(self, controller):
|
|
"""Registers controller extensions with this resource."""
|
|
|
|
extensions = getattr(controller, 'wsgi_extensions', [])
|
|
for method_name, action_name in extensions:
|
|
# Look up the extending method
|
|
extension = getattr(controller, method_name)
|
|
|
|
if action_name:
|
|
# Extending an action...
|
|
if action_name not in self.wsgi_action_extensions:
|
|
self.wsgi_action_extensions[action_name] = []
|
|
self.wsgi_action_extensions[action_name].append(extension)
|
|
else:
|
|
# Extending a regular method
|
|
if method_name not in self.wsgi_extensions:
|
|
self.wsgi_extensions[method_name] = []
|
|
self.wsgi_extensions[method_name].append(extension)
|
|
|
|
def get_action_args(self, request_environment):
|
|
"""Parse dictionary created by routes library."""
|
|
|
|
# NOTE(Vek): Check for get_action_args() override in the
|
|
# controller
|
|
if hasattr(self.controller, 'get_action_args'):
|
|
return self.controller.get_action_args(request_environment)
|
|
|
|
try:
|
|
args = request_environment['wsgiorg.routing_args'][1].copy()
|
|
except (KeyError, IndexError, AttributeError):
|
|
return {}
|
|
|
|
try:
|
|
del args['controller']
|
|
except KeyError:
|
|
pass
|
|
|
|
try:
|
|
del args['format']
|
|
except KeyError:
|
|
pass
|
|
|
|
return args
|
|
|
|
def get_body(self, request):
|
|
try:
|
|
content_type = request.get_content_type()
|
|
except exception.InvalidContentType:
|
|
LOG.debug(_("Unrecognized Content-Type provided in request"))
|
|
return None, ''
|
|
|
|
if not content_type:
|
|
LOG.debug(_("No Content-Type provided in request"))
|
|
return None, ''
|
|
|
|
if len(request.body) <= 0:
|
|
LOG.debug(_("Empty body provided in request"))
|
|
return None, ''
|
|
|
|
return content_type, request.body
|
|
|
|
def deserialize(self, meth, content_type, body):
|
|
meth_deserializers = getattr(meth, 'wsgi_deserializers', {})
|
|
try:
|
|
mtype = _MEDIA_TYPE_MAP.get(content_type, content_type)
|
|
if mtype in meth_deserializers:
|
|
deserializer = meth_deserializers[mtype]
|
|
else:
|
|
deserializer = self.default_deserializers[mtype]
|
|
except (KeyError, TypeError):
|
|
raise exception.InvalidContentType(content_type=content_type)
|
|
|
|
return deserializer().deserialize(body)
|
|
|
|
def pre_process_extensions(self, extensions, request, action_args):
|
|
# List of callables for post-processing extensions
|
|
post = []
|
|
|
|
for ext in extensions:
|
|
if inspect.isgeneratorfunction(ext):
|
|
response = None
|
|
|
|
# If it's a generator function, the part before the
|
|
# yield is the preprocessing stage
|
|
try:
|
|
with ResourceExceptionHandler():
|
|
gen = ext(req=request, **action_args)
|
|
response = gen.next()
|
|
except Fault as ex:
|
|
response = ex
|
|
|
|
# We had a response...
|
|
if response:
|
|
return response, []
|
|
|
|
# No response, queue up generator for post-processing
|
|
post.append(gen)
|
|
else:
|
|
# Regular functions only perform post-processing
|
|
post.append(ext)
|
|
|
|
# Run post-processing in the reverse order
|
|
return None, reversed(post)
|
|
|
|
def post_process_extensions(self, extensions, resp_obj, request,
|
|
action_args):
|
|
for ext in extensions:
|
|
response = None
|
|
if inspect.isgenerator(ext):
|
|
# If it's a generator, run the second half of
|
|
# processing
|
|
try:
|
|
with ResourceExceptionHandler():
|
|
response = ext.send(resp_obj)
|
|
except StopIteration:
|
|
# Normal exit of generator
|
|
continue
|
|
except Fault as ex:
|
|
response = ex
|
|
else:
|
|
# Regular functions get post-processing...
|
|
try:
|
|
with ResourceExceptionHandler():
|
|
response = ext(req=request, resp_obj=resp_obj,
|
|
**action_args)
|
|
except Fault as ex:
|
|
response = ex
|
|
|
|
# We had a response...
|
|
if response:
|
|
return response
|
|
|
|
return None
|
|
|
|
@webob.dec.wsgify(RequestClass=Request)
|
|
def __call__(self, request):
|
|
"""WSGI method that controls (de)serialization and method dispatch."""
|
|
|
|
LOG.info("%(method)s %(url)s" % {"method": request.method,
|
|
"url": request.url})
|
|
|
|
# Identify the action, its arguments, and the requested
|
|
# content type
|
|
action_args = self.get_action_args(request.environ)
|
|
action = action_args.pop('action', None)
|
|
content_type, body = self.get_body(request)
|
|
accept = request.best_match_content_type()
|
|
|
|
# NOTE(Vek): Splitting the function up this way allows for
|
|
# auditing by external tools that wrap the existing
|
|
# function. If we try to audit __call__(), we can
|
|
# run into troubles due to the @webob.dec.wsgify()
|
|
# decorator.
|
|
try:
|
|
return self._process_stack(request, action, action_args,
|
|
content_type, body, accept)
|
|
except expat.ExpatError:
|
|
msg = _("Invalid XML in request body")
|
|
return Fault(webob.exc.HTTPBadRequest(explanation=msg))
|
|
except LookupError as e:
|
|
#NOTE(Vijaya Erukala): XML input such as
|
|
# <?xml version="1.0" encoding="TF-8"?>
|
|
# raises LookupError: unknown encoding: TF-8
|
|
return Fault(webob.exc.HTTPBadRequest(explanation=unicode(e)))
|
|
|
|
def _process_stack(self, request, action, action_args,
|
|
content_type, body, accept):
|
|
"""Implement the processing stack."""
|
|
|
|
# Get the implementing method
|
|
try:
|
|
meth, extensions = self.get_method(request, action,
|
|
content_type, body)
|
|
except (AttributeError, TypeError):
|
|
return Fault(webob.exc.HTTPNotFound())
|
|
except KeyError as ex:
|
|
msg = _("There is no such action: %s") % ex.args[0]
|
|
return Fault(webob.exc.HTTPBadRequest(explanation=msg))
|
|
except exception.MalformedRequestBody:
|
|
msg = _("Malformed request body")
|
|
return Fault(webob.exc.HTTPBadRequest(explanation=msg))
|
|
|
|
# Now, deserialize the request body...
|
|
try:
|
|
if content_type:
|
|
contents = self.deserialize(meth, content_type, body)
|
|
else:
|
|
contents = {}
|
|
except exception.InvalidContentType:
|
|
msg = _("Unsupported Content-Type")
|
|
return Fault(webob.exc.HTTPBadRequest(explanation=msg))
|
|
except exception.MalformedRequestBody:
|
|
msg = _("Malformed request body")
|
|
return Fault(webob.exc.HTTPBadRequest(explanation=msg))
|
|
|
|
# Update the action args
|
|
action_args.update(contents)
|
|
|
|
project_id = action_args.pop("project_id", None)
|
|
context = request.environ.get('nova.context')
|
|
if (context and project_id and (project_id != context.project_id)):
|
|
msg = _("Malformed request url")
|
|
return Fault(webob.exc.HTTPBadRequest(explanation=msg))
|
|
|
|
# Run pre-processing extensions
|
|
response, post = self.pre_process_extensions(extensions,
|
|
request, action_args)
|
|
|
|
if not response:
|
|
try:
|
|
with ResourceExceptionHandler():
|
|
action_result = self.dispatch(meth, request, action_args)
|
|
except Fault as ex:
|
|
response = ex
|
|
|
|
if not response:
|
|
# No exceptions; convert action_result into a
|
|
# ResponseObject
|
|
resp_obj = None
|
|
if type(action_result) is dict or action_result is None:
|
|
resp_obj = ResponseObject(action_result)
|
|
elif isinstance(action_result, ResponseObject):
|
|
resp_obj = action_result
|
|
else:
|
|
response = action_result
|
|
|
|
# Run post-processing extensions
|
|
if resp_obj:
|
|
_set_request_id_header(request, resp_obj)
|
|
# Do a preserialize to set up the response object
|
|
serializers = getattr(meth, 'wsgi_serializers', {})
|
|
resp_obj._bind_method_serializers(serializers)
|
|
if hasattr(meth, 'wsgi_code'):
|
|
resp_obj._default_code = meth.wsgi_code
|
|
resp_obj.preserialize(accept, self.default_serializers)
|
|
|
|
# Process post-processing extensions
|
|
response = self.post_process_extensions(post, resp_obj,
|
|
request, action_args)
|
|
|
|
if resp_obj and not response:
|
|
response = resp_obj.serialize(request, accept,
|
|
self.default_serializers)
|
|
|
|
try:
|
|
msg_dict = dict(url=request.url, status=response.status_int)
|
|
msg = _("%(url)s returned with HTTP %(status)d") % msg_dict
|
|
except AttributeError, e:
|
|
msg_dict = dict(url=request.url, e=e)
|
|
msg = _("%(url)s returned a fault: %(e)s") % msg_dict
|
|
|
|
LOG.info(msg)
|
|
|
|
return response
|
|
|
|
def get_method(self, request, action, content_type, body):
|
|
meth, extensions = self._get_method(request,
|
|
action,
|
|
content_type,
|
|
body)
|
|
if self.inherits:
|
|
_meth, parent_ext = self.inherits.get_method(request,
|
|
action,
|
|
content_type,
|
|
body)
|
|
extensions.extend(parent_ext)
|
|
return meth, extensions
|
|
|
|
def _get_method(self, request, action, content_type, body):
|
|
"""Look up the action-specific method and its extensions."""
|
|
|
|
# Look up the method
|
|
try:
|
|
if not self.controller:
|
|
meth = getattr(self, action)
|
|
else:
|
|
meth = getattr(self.controller, action)
|
|
except AttributeError:
|
|
if (not self.wsgi_actions or
|
|
action not in ['action', 'create', 'delete', 'update',
|
|
'show']):
|
|
# Propagate the error
|
|
raise
|
|
else:
|
|
return meth, self.wsgi_extensions.get(action, [])
|
|
|
|
if action == 'action':
|
|
# OK, it's an action; figure out which action...
|
|
mtype = _MEDIA_TYPE_MAP.get(content_type)
|
|
action_name = self.action_peek[mtype](body)
|
|
else:
|
|
action_name = action
|
|
|
|
# Look up the action method
|
|
return (self.wsgi_actions[action_name],
|
|
self.wsgi_action_extensions.get(action_name, []))
|
|
|
|
def dispatch(self, method, request, action_args):
|
|
"""Dispatch a call to the action-specific method."""
|
|
|
|
return method(req=request, **action_args)
|
|
|
|
|
|
def action(name):
|
|
"""Mark a function as an action.
|
|
|
|
The given name will be taken as the action key in the body.
|
|
|
|
This is also overloaded to allow extensions to provide
|
|
non-extending definitions of create and delete operations.
|
|
"""
|
|
|
|
def decorator(func):
|
|
func.wsgi_action = name
|
|
return func
|
|
return decorator
|
|
|
|
|
|
def extends(*args, **kwargs):
|
|
"""Indicate a function extends an operation.
|
|
|
|
Can be used as either::
|
|
|
|
@extends
|
|
def index(...):
|
|
pass
|
|
|
|
or as::
|
|
|
|
@extends(action='resize')
|
|
def _action_resize(...):
|
|
pass
|
|
"""
|
|
|
|
def decorator(func):
|
|
# Store enough information to find what we're extending
|
|
func.wsgi_extends = (func.__name__, kwargs.get('action'))
|
|
return func
|
|
|
|
# If we have positional arguments, call the decorator
|
|
if args:
|
|
return decorator(*args)
|
|
|
|
# OK, return the decorator instead
|
|
return decorator
|
|
|
|
|
|
class ControllerMetaclass(type):
|
|
"""Controller metaclass.
|
|
|
|
This metaclass automates the task of assembling a dictionary
|
|
mapping action keys to method names.
|
|
"""
|
|
|
|
def __new__(mcs, name, bases, cls_dict):
|
|
"""Adds the wsgi_actions dictionary to the class."""
|
|
|
|
# Find all actions
|
|
actions = {}
|
|
extensions = []
|
|
# start with wsgi actions from base classes
|
|
for base in bases:
|
|
actions.update(getattr(base, 'wsgi_actions', {}))
|
|
for key, value in cls_dict.items():
|
|
if not callable(value):
|
|
continue
|
|
if getattr(value, 'wsgi_action', None):
|
|
actions[value.wsgi_action] = key
|
|
elif getattr(value, 'wsgi_extends', None):
|
|
extensions.append(value.wsgi_extends)
|
|
|
|
# Add the actions and extensions to the class dict
|
|
cls_dict['wsgi_actions'] = actions
|
|
cls_dict['wsgi_extensions'] = extensions
|
|
|
|
return super(ControllerMetaclass, mcs).__new__(mcs, name, bases,
|
|
cls_dict)
|
|
|
|
|
|
class Controller(object):
|
|
"""Default controller."""
|
|
|
|
__metaclass__ = ControllerMetaclass
|
|
|
|
_view_builder_class = None
|
|
|
|
def __init__(self, view_builder=None):
|
|
"""Initialize controller with a view builder instance."""
|
|
if view_builder:
|
|
self._view_builder = view_builder
|
|
elif self._view_builder_class:
|
|
self._view_builder = self._view_builder_class()
|
|
else:
|
|
self._view_builder = None
|
|
|
|
@staticmethod
|
|
def is_valid_body(body, entity_name):
|
|
if not (body and entity_name in body):
|
|
return False
|
|
|
|
def is_dict(d):
|
|
try:
|
|
d.get(None)
|
|
return True
|
|
except AttributeError:
|
|
return False
|
|
|
|
if not is_dict(body[entity_name]):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
class Fault(webob.exc.HTTPException):
|
|
"""Wrap webob.exc.HTTPException to provide API friendly response."""
|
|
|
|
_fault_names = {
|
|
400: "badRequest",
|
|
401: "unauthorized",
|
|
403: "forbidden",
|
|
404: "itemNotFound",
|
|
405: "badMethod",
|
|
409: "conflictingRequest",
|
|
413: "overLimit",
|
|
415: "badMediaType",
|
|
501: "notImplemented",
|
|
503: "serviceUnavailable"}
|
|
|
|
def __init__(self, exception):
|
|
"""Create a Fault for the given webob.exc.exception."""
|
|
self.wrapped_exc = exception
|
|
self.status_int = exception.status_int
|
|
|
|
@webob.dec.wsgify(RequestClass=Request)
|
|
def __call__(self, req):
|
|
"""Generate a WSGI response based on the exception passed to ctor."""
|
|
# Replace the body with fault details.
|
|
code = self.wrapped_exc.status_int
|
|
fault_name = self._fault_names.get(code, "computeFault")
|
|
fault_data = {
|
|
fault_name: {
|
|
'code': code,
|
|
'message': self.wrapped_exc.explanation}}
|
|
if code == 413:
|
|
retry = self.wrapped_exc.headers.get('Retry-After', None)
|
|
if retry:
|
|
fault_data[fault_name]['retryAfter'] = retry
|
|
|
|
# 'code' is an attribute on the fault tag itself
|
|
metadata = {'attributes': {fault_name: 'code'}}
|
|
|
|
xml_serializer = XMLDictSerializer(metadata, XMLNS_V11)
|
|
|
|
content_type = req.best_match_content_type()
|
|
serializer = {
|
|
'application/xml': xml_serializer,
|
|
'application/json': JSONDictSerializer(),
|
|
}[content_type]
|
|
|
|
self.wrapped_exc.body = serializer.serialize(fault_data)
|
|
self.wrapped_exc.content_type = content_type
|
|
_set_request_id_header(req, self.wrapped_exc.headers)
|
|
|
|
return self.wrapped_exc
|
|
|
|
def __str__(self):
|
|
return self.wrapped_exc.__str__()
|
|
|
|
|
|
class OverLimitFault(webob.exc.HTTPException):
|
|
"""
|
|
Rate-limited request response.
|
|
"""
|
|
|
|
def __init__(self, message, details, retry_time):
|
|
"""
|
|
Initialize new `OverLimitFault` with relevant information.
|
|
"""
|
|
hdrs = OverLimitFault._retry_after(retry_time)
|
|
self.wrapped_exc = webob.exc.HTTPRequestEntityTooLarge(headers=hdrs)
|
|
self.content = {
|
|
"overLimit": {
|
|
"code": self.wrapped_exc.status_int,
|
|
"message": message,
|
|
"details": details,
|
|
"retryAfter": hdrs['Retry-After'],
|
|
},
|
|
}
|
|
|
|
@staticmethod
|
|
def _retry_after(retry_time):
|
|
delay = int(math.ceil(retry_time - time.time()))
|
|
retry_after = delay if delay > 0 else 0
|
|
headers = {'Retry-After': '%d' % retry_after}
|
|
return headers
|
|
|
|
@webob.dec.wsgify(RequestClass=Request)
|
|
def __call__(self, request):
|
|
"""
|
|
Return the wrapped exception with a serialized body conforming to our
|
|
error format.
|
|
"""
|
|
content_type = request.best_match_content_type()
|
|
metadata = {"attributes": {"overLimit": ["code", "retryAfter"]}}
|
|
|
|
xml_serializer = XMLDictSerializer(metadata, XMLNS_V11)
|
|
serializer = {
|
|
'application/xml': xml_serializer,
|
|
'application/json': JSONDictSerializer(),
|
|
}[content_type]
|
|
|
|
content = serializer.serialize(self.content)
|
|
self.wrapped_exc.body = content
|
|
self.wrapped_exc.content_type = content_type
|
|
|
|
return self.wrapped_exc
|
|
|
|
|
|
def _set_request_id_header(req, headers):
|
|
context = req.environ.get('nova.context')
|
|
if context:
|
|
headers['x-compute-request-id'] = context.request_id
|