From 96b9a548521cefb7c1b7dd5229c89b8fec53de85 Mon Sep 17 00:00:00 2001 From: Rajaram Mallya Date: Wed, 7 Sep 2011 17:05:16 +0530 Subject: Rajaram/Vinkesh|Added nova's serializaiton classes into common --- openstack/common/__init__.py | 2 +- openstack/common/wsgi.py | 484 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 419 insertions(+), 67 deletions(-) (limited to 'openstack') diff --git a/openstack/common/__init__.py b/openstack/common/__init__.py index b22496f..64da808 100644 --- a/openstack/common/__init__.py +++ b/openstack/common/__init__.py @@ -15,5 +15,5 @@ # License for the specific language governing permissions and limitations # under the License. -# TODO(jaypipes) Code in this module is intended to be ported to the eventual +# TODO(jaypipes) Code in this module is intended to be ported to the eventual # openstack-common library diff --git a/openstack/common/wsgi.py b/openstack/common/wsgi.py index 8faa6dc..628b3a4 100644 --- a/openstack/common/wsgi.py +++ b/openstack/common/wsgi.py @@ -15,26 +15,30 @@ # License for the specific language governing permissions and limitations # under the License. -""" -Utility methods for working with WSGI servers -""" +"""Utility methods for working with WSGI servers.""" -import json -import logging -import sys import datetime - import eventlet import eventlet.wsgi + eventlet.patcher.monkey_patch(all=False, socket=True) + +import json +import logging +import sys import routes import routes.middleware import webob.dec import webob.exc +from xml.dom import minidom +from xml.parsers import expat from openstack.common import exception +LOG = logging.getLogger('wsgi') + + class WritableLogger(object): """A thin wrapper that responds to `write` and logs.""" @@ -209,14 +213,19 @@ class Request(webob.Request): """Add some Openstack API-specific logic to the base webob.Request.""" - def best_match_content_type(self): + def best_match_content_type(self, supported_content_types=None): """Determine the requested response content-type.""" - supported = ('application/json',) - bm = self.accept.best_match(supported) + supported_content_types = (supported_content_types + or ("application/xml", + "application/json",)) + bm = self.accept.best_match(supported_content_types) return bm or 'application/json' - def get_content_type(self, allowed_content_types): + def get_content_type(self, allowed_content_types=None): """Determine content type of the request body.""" + + allowed_content_types = allowed_content_types or ("application/xml", + "application/json",) if not "Content-Type" in self.headers: raise exception.InvalidContentType(content_type=None) @@ -228,45 +237,6 @@ class Request(webob.Request): return content_type -class JSONRequestDeserializer(object): - def has_body(self, request): - """ - Returns whether a Webob.Request object will possess an entity body. - - :param request: Webob.Request object - """ - if 'transfer-encoding' in request.headers: - return True - elif request.content_length > 0: - return True - - return False - - def from_json(self, datastring): - return json.loads(datastring) - - def default(self, request): - if self.has_body(request): - return {'body': self.from_json(request.body)} - else: - return {} - - -class JSONResponseSerializer(object): - - def to_json(self, data): - def sanitizer(obj): - if isinstance(obj, datetime.datetime): - return obj.isoformat() - return obj - - return json.dumps(data, default=sanitizer) - - def default(self, response, result): - response.headers.add('Content-Type', 'application/json') - response.body = self.to_json(result) - - class Resource(object): """ WSGI app that handles (de)serialization and controller dispatch. @@ -284,7 +254,7 @@ class Resource(object): may raise a webob.exc exception or return a dict, which will be serialized by requested content type. """ - def __init__(self, controller, deserializer, serializer): + def __init__(self, controller, deserializer=None, serializer=None): """ :param controller: object that implement methods created by routes lib :param deserializer: object that supports webob request deserialization @@ -293,33 +263,26 @@ class Resource(object): through controller-like actions """ self.controller = controller - self.serializer = serializer - self.deserializer = deserializer + self.serializer = serializer or ResponseSerializer() + self.deserializer = deserializer or RequestDeserializer() @webob.dec.wsgify(RequestClass=Request) def __call__(self, request): """WSGI method that controls (de)serialization and method dispatch.""" - action_args = self.get_action_args(request.environ) - action = action_args.pop('action', None) - - deserialized_params = self.deserialize_request(action, request) - action_args.update(deserialized_params) + action, action_args, accept = self.deserialize_request(request) action_result = self.execute_action(action, request, **action_args) try: - return self.serialize_response(action, action_result, request) + return self.serialize_response(action, action_result, accept) # return unserializable result (typically a webob exc) except Exception: return action_result - def deserialize_request(self, action, request): - return self.dispatch(self.deserializer, action, request) + def deserialize_request(self, request): + return self.deserializer.deserialize(request) - def serialize_response(self, action, action_result, request): - response = webob.Response() - self.dispatch(self.serializer, action, response, - action_result, request) - return response + def serialize_response(self, action, action_result, accept): + return self.serializer.serialize(action_result, accept, action) def execute_action(self, action, request, **action_args): return self.dispatch(self.controller, action, request, **action_args) @@ -351,3 +314,392 @@ class Resource(object): pass return args + + +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 json.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(self, parent, name): + """Search a nodes children for the first child with a given name""" + for node in parent.childNodes: + if node.nodeName == 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.nodeName == 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 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): + def sanitizer(obj): + if isinstance(obj, datetime.datetime): + _dtime = obj - datetime.timedelta(microseconds=obj.microsecond) + return _dtime.isoformat() + return obj + return json.dumps(data, default=sanitizer) + + +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.toprettyxml(indent=' ', encoding='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 type(data) is 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 type(data) is 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 + + +class ResponseHeadersSerializer(ActionDispatcher): + """Default response headers serialization""" + + def serialize(self, response, data, action): + self.dispatch(response, data, action=action) + + def default(self, response, data): + response.status_int = 200 + + +class ResponseSerializer(object): + """Encode the necessary pieces into a response object""" + + def __init__(self, body_serializers=None, headers_serializer=None): + self.body_serializers = { + 'application/xml': XMLDictSerializer(), + 'application/json': JSONDictSerializer(), + } + self.body_serializers.update(body_serializers or {}) + + self.headers_serializer = headers_serializer or \ + ResponseHeadersSerializer() + + def serialize(self, response_data, content_type, action='default'): + """Serialize a dict into a string and wrap in a wsgi.Request object. + + :param response_data: dict produced by the Controller + :param content_type: expected mimetype of serialized response body + + """ + response = webob.Response() + self.serialize_headers(response, response_data, action) + self.serialize_body(response, response_data, content_type, action) + return response + + def serialize_headers(self, response, data, action): + self.headers_serializer.serialize(response, data, action) + + def serialize_body(self, response, data, content_type, action): + response.headers['Content-Type'] = content_type + if data is not None: + serializer = self.get_body_serializer(content_type) + response.body = serializer.serialize(data, action) + + def get_body_serializer(self, content_type): + try: + return self.body_serializers[content_type] + except (KeyError, TypeError): + raise exception.InvalidContentType(content_type=content_type) + + +class RequestHeadersDeserializer(ActionDispatcher): + """Default request headers deserializer""" + + def deserialize(self, request, action): + return self.dispatch(request, action=action) + + def default(self, request): + return {} + + +class RequestDeserializer(object): + """Break up a Request object into more useful pieces.""" + + def __init__(self, body_deserializers=None, headers_deserializer=None, + supported_content_types=None): + + self.supported_content_types = supported_content_types or \ + ('application/json', 'application/xml') + + self.body_deserializers = { + 'application/xml': XMLDeserializer(), + 'application/json': JSONDeserializer(), + } + self.body_deserializers.update(body_deserializers or {}) + + self.headers_deserializer = headers_deserializer or \ + RequestHeadersDeserializer() + + def deserialize(self, request): + """Extract necessary pieces of the request. + + :param request: Request object + :returns tuple of expected controller action name, dictionary of + keyword arguments to pass to the controller, the expected + content type of the response + + """ + action_args = self.get_action_args(request.environ) + action = action_args.pop('action', None) + + action_args.update(self.deserialize_headers(request, action)) + action_args.update(self.deserialize_body(request, action)) + + accept = self.get_expected_content_type(request) + + return (action, action_args, accept) + + def deserialize_headers(self, request, action): + return self.headers_deserializer.deserialize(request, action) + + def deserialize_body(self, request, action): + if not len(request.body) > 0: + LOG.debug(_("Empty body provided in request")) + return {} + + try: + content_type = request.get_content_type() + except exception.InvalidContentType: + LOG.debug(_("Unrecognized Content-Type provided in request")) + return {} + + if content_type is None: + LOG.debug(_("No Content-Type provided in request")) + return {} + + try: + deserializer = self.get_body_deserializer(content_type) + except exception.InvalidContentType: + LOG.debug(_("Unable to deserialize body as provided Content-Type")) + raise + + return deserializer.deserialize(request.body, action) + + def get_body_deserializer(self, content_type): + try: + return self.body_deserializers[content_type] + except (KeyError, TypeError): + raise exception.InvalidContentType(content_type=content_type) + + def get_expected_content_type(self, request): + return request.best_match_content_type(self.supported_content_types) + + def get_action_args(self, request_environment): + """Parse dictionary created by routes library.""" + try: + args = request_environment['wsgiorg.routing_args'][1].copy() + except Exception: + return {} + + try: + del args['controller'] + except KeyError: + pass + + try: + del args['format'] + except KeyError: + pass + + return args -- cgit From 2a529c6cb7cfed08a7afe8fc0f8249bc9bdb2621 Mon Sep 17 00:00:00 2001 From: Rajaram Mallya Date: Wed, 7 Sep 2011 17:07:19 +0530 Subject: Vinkesh/Rajaram|Added nova's extension framework into common and tests for it --- openstack/common/extensions.py | 505 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 openstack/common/extensions.py (limited to 'openstack') diff --git a/openstack/common/extensions.py b/openstack/common/extensions.py new file mode 100644 index 0000000..4bef1e7 --- /dev/null +++ b/openstack/common/extensions.py @@ -0,0 +1,505 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# Copyright 2011 Justin Santa Barbara +# 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 imp +import os +import routes +import webob.dec +import webob.exc +import logging +from lxml import etree + +from openstack.common import exception +from openstack.common import wsgi + + +LOG = logging.getLogger('extensions') + + +class ExtensionDescriptor(object): + """Base class that defines the contract for extensions. + + Note that you don't have to derive from this class to have a valid + extension; it is purely a convenience. + + """ + + def get_name(self): + """The name of the extension. + + e.g. 'Fox In Socks' + + """ + raise NotImplementedError() + + def get_alias(self): + """The alias for the extension. + + e.g. 'FOXNSOX' + + """ + raise NotImplementedError() + + def get_description(self): + """Friendly description for the extension. + + e.g. 'The Fox In Socks Extension' + + """ + raise NotImplementedError() + + def get_namespace(self): + """The XML namespace for the extension. + + e.g. 'http://www.fox.in.socks/api/ext/pie/v1.0' + + """ + raise NotImplementedError() + + def get_updated(self): + """The timestamp when the extension was last updated. + + e.g. '2011-01-22T13:25:27-06:00' + + """ + # NOTE(justinsb): Not sure of the purpose of this is, vs the XML NS + raise NotImplementedError() + + def get_resources(self): + """List of extensions.ResourceExtension extension objects. + + Resources define new nouns, and are accessible through URLs. + + """ + resources = [] + return resources + + def get_actions(self): + """List of extensions.ActionExtension extension objects. + + Actions are verbs callable from the API. + + """ + actions = [] + return actions + + def get_request_extensions(self): + """List of extensions.RequestException extension objects. + + Request extensions are used to handle custom request data. + + """ + request_exts = [] + return request_exts + + +class ActionExtensionController(object): + def __init__(self, application): + self.application = application + self.action_handlers = {} + + def add_action(self, action_name, handler): + self.action_handlers[action_name] = handler + + def action(self, req, id, body): + for action_name, handler in self.action_handlers.iteritems(): + if action_name in body: + return handler(body, req, id) + # no action handler found (bump to downstream application) + res = self.application + return res + + +class ActionExtensionResource(wsgi.Resource): + + def __init__(self, application): + controller = ActionExtensionController(application) + wsgi.Resource.__init__(self, controller) + + def add_action(self, action_name, handler): + self.controller.add_action(action_name, handler) + + +class RequestExtensionController(object): + + def __init__(self, application): + self.application = application + self.handlers = [] + + def add_handler(self, handler): + self.handlers.append(handler) + + def process(self, req, *args, **kwargs): + res = req.get_response(self.application) + # currently request handlers are un-ordered + for handler in self.handlers: + res = handler(req, res) + return res + + +class RequestExtensionResource(wsgi.Resource): + + def __init__(self, application): + controller = RequestExtensionController(application) + wsgi.Resource.__init__(self, controller) + + def add_handler(self, handler): + self.controller.add_handler(handler) + + +class ExtensionsResource(wsgi.Resource): + + def __init__(self, extension_manager): + self.extension_manager = extension_manager + + def _translate(self, ext): + ext_data = {} + ext_data['name'] = ext.get_name() + ext_data['alias'] = ext.get_alias() + ext_data['description'] = ext.get_description() + ext_data['namespace'] = ext.get_namespace() + ext_data['updated'] = ext.get_updated() + ext_data['links'] = [] # TODO(dprince): implement extension links + return ext_data + + def index(self, req): + extensions = [] + for _alias, ext in self.extension_manager.extensions.iteritems(): + extensions.append(self._translate(ext)) + return dict(extensions=extensions) + + def show(self, req, id): + # NOTE(dprince): the extensions alias is used as the 'id' for show + ext = self.extension_manager.extensions.get(id, None) + if not ext: + raise webob.exc.HTTPNotFound( + _("Extension with alias %s does not exist") % id) + + return dict(extension=self._translate(ext)) + + def delete(self, req, id): + raise webob.exc.HTTPNotFound() + + def create(self, req): + raise webob.exc.HTTPNotFound() + + +class ExtensionMiddleware(wsgi.Middleware): + """Extensions middleware for WSGI.""" + + def _action_ext_resources(self, application, ext_mgr, mapper): + """Return a dict of ActionExtensionResource-s by collection.""" + action_resources = {} + for action in ext_mgr.get_actions(): + if not action.collection in action_resources.keys(): + resource = ActionExtensionResource(application) + mapper.connect("/%s/:(id)/action.:(format)" % + action.collection, + action='action', + controller=resource, + conditions=dict(method=['POST'])) + mapper.connect("/%s/:(id)/action" % + action.collection, + action='action', + controller=resource, + conditions=dict(method=['POST'])) + action_resources[action.collection] = resource + + return action_resources + + def _request_ext_resources(self, application, ext_mgr, mapper): + """Returns a dict of RequestExtensionResource-s by collection.""" + request_ext_resources = {} + for req_ext in ext_mgr.get_request_extensions(): + if not req_ext.key in request_ext_resources.keys(): + resource = RequestExtensionResource(application) + mapper.connect(req_ext.url_route + '.:(format)', + action='process', + controller=resource, + conditions=req_ext.conditions) + + mapper.connect(req_ext.url_route, + action='process', + controller=resource, + conditions=req_ext.conditions) + request_ext_resources[req_ext.key] = resource + + return request_ext_resources + + def __init__(self, application, ext_mgr): + self.ext_mgr = ext_mgr + + mapper = routes.Mapper() + + # extended resources + for resource in ext_mgr.get_resources(): + LOG.debug(_('Extended resource: %s'), + resource.collection) + + kargs = dict( + controller=wsgi.Resource( + resource.controller, resource.deserializer, + resource.serializer), + collection=resource.collection_actions, + member=resource.member_actions) + + if resource.parent: + kargs['parent_resource'] = resource.parent + + mapper.resource(resource.collection, resource.collection, **kargs) + + # extended actions + action_resources = self._action_ext_resources(application, ext_mgr, + mapper) + for action in ext_mgr.get_actions(): + LOG.debug(_('Extended action: %s'), action.action_name) + resource = action_resources[action.collection] + resource.add_action(action.action_name, action.handler) + + # extended requests + req_controllers = self._request_ext_resources(application, ext_mgr, + mapper) + for request_ext in ext_mgr.get_request_extensions(): + LOG.debug(_('Extended request: %s'), request_ext.key) + controller = req_controllers[request_ext.key] + controller.add_handler(request_ext.handler) + + self._router = routes.middleware.RoutesMiddleware(self._dispatch, + mapper) + + super(ExtensionMiddleware, self).__init__(application) + + @webob.dec.wsgify(RequestClass=wsgi.Request) + def __call__(self, req): + """Route the incoming request with router.""" + req.environ['extended.app'] = self.application + return self._router + + @staticmethod + @webob.dec.wsgify(RequestClass=wsgi.Request) + def _dispatch(req): + """Dispatch the request. + + Returns the routed WSGI app's response or defers to the extended + application. + + """ + match = req.environ['wsgiorg.routing_args'][1] + if not match: + return req.environ['extended.app'] + app = match['controller'] + return app + + +class ExtensionManager(object): + """Load extensions from the configured extension path. + + See nova/tests/api/openstack/extensions/foxinsocks/extension.py for an + example extension implementation. + + """ + + def __init__(self, path): + LOG.debug(_('Initializing extension manager.')) + + self.path = path + self.extensions = {} + self._load_all_extensions() + + def get_resources(self): + """Returns a list of ResourceExtension objects.""" + resources = [] + resources.append(ResourceExtension('extensions', + ExtensionsResource(self))) + for alias, ext in self.extensions.iteritems(): + try: + resources.extend(ext.get_resources()) + except AttributeError: + # NOTE(dprince): Extension aren't required to have resource + # extensions + pass + return resources + + def get_actions(self): + """Returns a list of ActionExtension objects.""" + actions = [] + for alias, ext in self.extensions.iteritems(): + try: + actions.extend(ext.get_actions()) + except AttributeError: + # NOTE(dprince): Extension aren't required to have action + # extensions + pass + return actions + + def get_request_extensions(self): + """Returns a list of RequestExtension objects.""" + request_exts = [] + for alias, ext in self.extensions.iteritems(): + try: + request_exts.extend(ext.get_request_extensions()) + except AttributeError: + # NOTE(dprince): Extension aren't required to have request + # extensions + pass + return request_exts + + def _check_extension(self, extension): + """Checks for required methods in extension objects.""" + try: + LOG.debug(_('Ext name: %s'), extension.get_name()) + LOG.debug(_('Ext alias: %s'), extension.get_alias()) + LOG.debug(_('Ext description: %s'), extension.get_description()) + LOG.debug(_('Ext namespace: %s'), extension.get_namespace()) + LOG.debug(_('Ext updated: %s'), extension.get_updated()) + except AttributeError as ex: + LOG.exception(_("Exception loading extension: %s"), unicode(ex)) + return False + return True + + def _load_all_extensions(self): + """Load extensions from the configured path. + + Load extensions from the configured path. The extension name is + constructed from the module_name. If your extension module was named + widgets.py the extension class within that module should be + 'Widgets'. + + In addition, extensions are loaded from the 'contrib' directory. + + See nova/tests/api/openstack/extensions/foxinsocks.py for an example + extension implementation. + + """ + if os.path.exists(self.path): + self._load_all_extensions_from_path(self.path) + + contrib_path = os.path.join(os.path.dirname(__file__), "contrib") + if os.path.exists(contrib_path): + self._load_all_extensions_from_path(contrib_path) + + def _load_all_extensions_from_path(self, path): + for f in os.listdir(path): + LOG.debug(_('Loading extension file: %s'), f) + mod_name, file_ext = os.path.splitext(os.path.split(f)[-1]) + ext_path = os.path.join(path, f) + if file_ext.lower() == '.py' and not mod_name.startswith('_'): + mod = imp.load_source(mod_name, ext_path) + ext_name = mod_name[0].upper() + mod_name[1:] + new_ext_class = getattr(mod, ext_name, None) + if not new_ext_class: + LOG.warn(_('Did not find expected name ' + '"%(ext_name)s" in %(file)s'), + {'ext_name': ext_name, + 'file': ext_path}) + continue + new_ext = new_ext_class() + self.add_extension(new_ext) + + def add_extension(self, ext): + # Do nothing if the extension doesn't check out + if not self._check_extension(ext): + return + + alias = ext.get_alias() + LOG.debug(_('Loaded extension: %s'), alias) + + if alias in self.extensions: + raise exception.Error("Found duplicate extension: %s" % alias) + self.extensions[alias] = ext + + +class RequestExtension(object): + """Extend requests and responses of core nova OpenStack API resources. + + Provide a way to add data to responses and handle custom request data + that is sent to core nova OpenStack API controllers. + + """ + def __init__(self, method, url_route, handler): + self.url_route = url_route + self.handler = handler + self.conditions = dict(method=[method]) + self.key = "%s-%s" % (method, url_route) + + +class ActionExtension(object): + """Add custom actions to core nova OpenStack API resources.""" + + def __init__(self, collection, action_name, handler): + self.collection = collection + self.action_name = action_name + self.handler = handler + + +class ResourceExtension(object): + """Add top level resources to the OpenStack API in nova.""" + + def __init__(self, collection, controller, parent=None, + collection_actions=None, member_actions=None, + deserializer=None, serializer=None): + if not collection_actions: + collection_actions = {} + if not member_actions: + member_actions = {} + self.collection = collection + self.controller = controller + self.parent = parent + self.collection_actions = collection_actions + self.member_actions = member_actions + self.deserializer = deserializer + self.serializer = serializer + + +class ExtensionsXMLSerializer(wsgi.XMLDictSerializer): + +# NSMAP = {None: xmlutil.XMLNS_V11, 'atom': xmlutil.XMLNS_ATOM} + + def show(self, ext_dict): + ext = etree.Element('extension', nsmap=self.NSMAP) + self._populate_ext(ext, ext_dict['extension']) + return self._to_xml(ext) + + def index(self, exts_dict): + exts = etree.Element('extensions', nsmap=self.NSMAP) + for ext_dict in exts_dict['extensions']: + ext = etree.SubElement(exts, 'extension') + self._populate_ext(ext, ext_dict) + return self._to_xml(exts) + + def _populate_ext(self, ext_elem, ext_dict): + """Populate an extension xml element from a dict.""" + + ext_elem.set('name', ext_dict['name']) + ext_elem.set('namespace', ext_dict['namespace']) + ext_elem.set('alias', ext_dict['alias']) + ext_elem.set('updated', ext_dict['updated']) + desc = etree.Element('description') + desc.text = ext_dict['description'] + ext_elem.append(desc) + for link in ext_dict.get('links', []): + elem = etree.SubElement(ext_elem, '{%s}link' % xmlutil.XMLNS_ATOM) + elem.set('rel', link['rel']) + elem.set('href', link['href']) + elem.set('type', link['type']) + return ext_elem + + def _to_xml(self, root): + """Convert the xml object to an xml string.""" + + return etree.tostring(root, encoding='UTF-8') -- cgit From 87dceb46c403305264387925efd5baa1ecb91c54 Mon Sep 17 00:00:00 2001 From: Rajaram Mallya Date: Wed, 7 Sep 2011 18:08:28 +0530 Subject: Rajaram/Vinkesh | Fixed the extension bug where custom collection actions' routes in resource extension were not getting registered --- openstack/common/extensions.py | 48 +++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 15 deletions(-) (limited to 'openstack') diff --git a/openstack/common/extensions.py b/openstack/common/extensions.py index 4bef1e7..7044227 100644 --- a/openstack/common/extensions.py +++ b/openstack/common/extensions.py @@ -247,21 +247,20 @@ class ExtensionMiddleware(wsgi.Middleware): mapper = routes.Mapper() # extended resources - for resource in ext_mgr.get_resources(): - LOG.debug(_('Extended resource: %s'), - resource.collection) - - kargs = dict( - controller=wsgi.Resource( - resource.controller, resource.deserializer, - resource.serializer), - collection=resource.collection_actions, - member=resource.member_actions) - - if resource.parent: - kargs['parent_resource'] = resource.parent - - mapper.resource(resource.collection, resource.collection, **kargs) + for resource_ext in ext_mgr.get_resources(): + LOG.debug(_('Extended resource: %s'), resource_ext.collection) + controller_resource = wsgi.Resource(resource_ext.controller, + resource_ext.deserializer, + resource_ext.serializer) + self._map_custom_collection_actions(resource_ext, mapper, + controller_resource) + kargs = dict(controller=controller_resource, + collection=resource_ext.collection_actions, + member=resource_ext.member_actions) + if resource_ext.parent: + kargs['parent_resource'] = resource_ext.parent + mapper.resource(resource_ext.collection, + resource_ext.collection, **kargs) # extended actions action_resources = self._action_ext_resources(application, ext_mgr, @@ -284,6 +283,25 @@ class ExtensionMiddleware(wsgi.Middleware): super(ExtensionMiddleware, self).__init__(application) + def _map_custom_collection_actions(self, resource_ext, mapper, + controller_resource): + for action, method in resource_ext.collection_actions.iteritems(): + parent = resource_ext.parent + conditions = dict(method=[method]) + path = "/%s/%s" % (resource_ext.collection, action) + + path_prefix = "" + if parent: + path_prefix = "/%s/{%s_id}" % (parent["collection_name"], + parent["member_name"]) + + with mapper.submapper(controller=controller_resource, + action=action, + path_prefix=path_prefix, + conditions=conditions) as submap: + submap.connect(path) + submap.connect("%s.:(format)" % path) + @webob.dec.wsgify(RequestClass=wsgi.Request) def __call__(self, req): """Route the incoming request with router.""" -- cgit From 02c95aeb2ffe112f7b60a1d3c53cdde22bc5db4d Mon Sep 17 00:00:00 2001 From: Rajaram Mallya Date: Thu, 8 Sep 2011 18:29:40 +0530 Subject: Rajaram/Vinkesh | Copied tests for wsgi from nova. Added default content/accept types in Request which can be overridden by projects. Copied tests for XML serialization of Extension Controller's action from nova --- openstack/common/exception.py | 4 + openstack/common/extensions.py | 17 ++- openstack/common/wsgi.py | 248 +++++++++++++++++++++-------------------- 3 files changed, 146 insertions(+), 123 deletions(-) (limited to 'openstack') diff --git a/openstack/common/exception.py b/openstack/common/exception.py index a81355e..ba32da5 100644 --- a/openstack/common/exception.py +++ b/openstack/common/exception.py @@ -139,5 +139,9 @@ class OpenstackException(Exception): return self._error_string +class MalformedRequestBody(OpenstackException): + message = "Malformed message body: %(reason)s" + + class InvalidContentType(OpenstackException): message = "Invalid content type %(content_type)s" diff --git a/openstack/common/extensions.py b/openstack/common/extensions.py index 7044227..d2fab36 100644 --- a/openstack/common/extensions.py +++ b/openstack/common/extensions.py @@ -27,8 +27,9 @@ from lxml import etree from openstack.common import exception from openstack.common import wsgi - LOG = logging.getLogger('extensions') +DEFAULT_XMLNS = "http://docs.openstack.org/" +XMLNS_ATOM = "http://www.w3.org/2005/Atom" class ExtensionDescriptor(object): @@ -166,6 +167,9 @@ class ExtensionsResource(wsgi.Resource): def __init__(self, extension_manager): self.extension_manager = extension_manager + body_serializers = {'application/xml': ExtensionsXMLSerializer()} + serializer = wsgi.ResponseSerializer(body_serializers=body_serializers) + super(ExtensionsResource, self).__init__(self, None, serializer) def _translate(self, ext): ext_data = {} @@ -342,8 +346,11 @@ class ExtensionManager(object): def get_resources(self): """Returns a list of ResourceExtension objects.""" resources = [] - resources.append(ResourceExtension('extensions', - ExtensionsResource(self))) + extension_resource = ExtensionsResource(self) + res_ext = ResourceExtension('extensions', + extension_resource, + serializer=extension_resource.serializer) + resources.append(res_ext) for alias, ext in self.extensions.iteritems(): try: resources.extend(ext.get_resources()) @@ -486,7 +493,7 @@ class ResourceExtension(object): class ExtensionsXMLSerializer(wsgi.XMLDictSerializer): -# NSMAP = {None: xmlutil.XMLNS_V11, 'atom': xmlutil.XMLNS_ATOM} + NSMAP = {None: DEFAULT_XMLNS, 'atom': XMLNS_ATOM} def show(self, ext_dict): ext = etree.Element('extension', nsmap=self.NSMAP) @@ -511,7 +518,7 @@ class ExtensionsXMLSerializer(wsgi.XMLDictSerializer): desc.text = ext_dict['description'] ext_elem.append(desc) for link in ext_dict.get('links', []): - elem = etree.SubElement(ext_elem, '{%s}link' % xmlutil.XMLNS_ATOM) + elem = etree.SubElement(ext_elem, '{%s}link' % XMLNS_ATOM) elem.set('rel', link['rel']) elem.set('href', link['href']) elem.set('type', link['type']) diff --git a/openstack/common/wsgi.py b/openstack/common/wsgi.py index 628b3a4..128ae8c 100644 --- a/openstack/common/wsgi.py +++ b/openstack/common/wsgi.py @@ -210,31 +210,47 @@ class Router(object): class Request(webob.Request): - """Add some Openstack API-specific logic to the base webob.Request.""" + default_request_content_types = ('application/json', 'application/xml') + default_accept_types = ('application/json', 'application/xml') + default_accept_type = 'application/json' + def best_match_content_type(self, supported_content_types=None): - """Determine the requested response content-type.""" - supported_content_types = (supported_content_types - or ("application/xml", - "application/json",)) + """Determine the requested response content-type. + + Based on the query extension then the Accept header. + Defaults to default_accept_type if we don't find a preference + + """ + supported_content_types = (supported_content_types or + self.default_accept_types) + + parts = self.path.rsplit('.', 1) + if len(parts) > 1: + ctype = 'application/{0}'.format(parts[1]) + if ctype in supported_content_types: + return ctype + bm = self.accept.best_match(supported_content_types) - return bm or 'application/json' + return bm or self.default_accept_type def get_content_type(self, allowed_content_types=None): - """Determine content type of the request body.""" + """Determine content type of the request body. - allowed_content_types = allowed_content_types or ("application/xml", - "application/json",) + Does not do any body introspection, only checks header + + """ if not "Content-Type" in self.headers: - raise exception.InvalidContentType(content_type=None) + return None content_type = self.content_type + allowed_content_types = (allowed_content_types or + self.default_request_content_types) if content_type not in allowed_content_types: raise exception.InvalidContentType(content_type=content_type) - else: - return content_type + return content_type class Resource(object): @@ -269,11 +285,19 @@ class Resource(object): @webob.dec.wsgify(RequestClass=Request) def __call__(self, request): """WSGI method that controls (de)serialization and method dispatch.""" - action, action_args, accept = self.deserialize_request(request) + + try: + action, action_args, accept = self.deserialize_request(request) + except exception.InvalidContentType: + msg = _("Unsupported Content-Type") + return webob.exc.HTTPUnsupportedMediaType(explanation=msg) + except exception.MalformedRequestBody: + msg = _("Malformed request body") + return webob.exc.HTTPBadRequest(explanation=msg) + action_result = self.execute_action(action, request, **action_args) try: return self.serialize_response(action, action_result, accept) - # return unserializable result (typically a webob exc) except Exception: return action_result @@ -329,107 +353,6 @@ class ActionDispatcher(object): 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 json.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(self, parent, name): - """Search a nodes children for the first child with a given name""" - for node in parent.childNodes: - if node.nodeName == 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.nodeName == 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 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""" @@ -619,8 +542,7 @@ class RequestDeserializer(object): def __init__(self, body_deserializers=None, headers_deserializer=None, supported_content_types=None): - self.supported_content_types = supported_content_types or \ - ('application/json', 'application/xml') + self.supported_content_types = supported_content_types self.body_deserializers = { 'application/xml': XMLDeserializer(), @@ -662,7 +584,7 @@ class RequestDeserializer(object): content_type = request.get_content_type() except exception.InvalidContentType: LOG.debug(_("Unrecognized Content-Type provided in request")) - return {} + raise if content_type is None: LOG.debug(_("No Content-Type provided in request")) @@ -703,3 +625,93 @@ class RequestDeserializer(object): pass return args + + +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 json.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(self, parent, name): + """Search a nodes children for the first child with a given name""" + for node in parent.childNodes: + if node.nodeName == 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.nodeName == 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 default(self, datastring): + return {'body': self._from_xml(datastring)} -- cgit