blueprint api-framework-essex
Addressing Dan's comments. This changeset provides: - improved framework for API versioning, with separated controllers for each API version - Taken from nova:'Resource' class in WSGI framework. This class is a container for serializers, deserializers and controller - Better deserialization thanks to resource class. _parse_request_params has been removed - Improved management of HTTP client errors NOTE: this changeset does not update the framework used by API extensions. Change-Id: I88a669ce418225c415e0da22e951762d0708e0a5
This commit is contained in:
parent
39eaed52fa
commit
5698d9b3f8
@ -21,14 +21,22 @@ api_extensions_path = server/lib/quantum/extensions
|
|||||||
[composite:quantum]
|
[composite:quantum]
|
||||||
use = egg:Paste#urlmap
|
use = egg:Paste#urlmap
|
||||||
/: quantumversions
|
/: quantumversions
|
||||||
/v1.0: quantumapi
|
/v1.0: quantumapi_v1_0
|
||||||
|
/v1.1: quantumapi_v1_1
|
||||||
|
|
||||||
[pipeline:quantumapi]
|
[pipeline:quantumapi_v1_0]
|
||||||
# By default, authentication is disabled.
|
# By default, authentication is disabled.
|
||||||
# To enable Keystone integration uncomment the
|
# To enable Keystone integration uncomment the
|
||||||
# following line and comment the next one
|
# following line and comment the next one
|
||||||
pipeline = extensions quantumapiapp
|
pipeline = extensions quantumapiapp_v1_0
|
||||||
#pipeline = authN extensions quantumapiapp
|
#pipeline = authN extensions quantumapiapp_v1_0
|
||||||
|
|
||||||
|
[pipeline:quantumapi_v1_1]
|
||||||
|
# By default, authentication is disabled.
|
||||||
|
# To enable Keystone integration uncomment the
|
||||||
|
# following line and comment the next one
|
||||||
|
pipeline = extensions quantumapiapp_v1_1
|
||||||
|
#pipeline = authN extensions quantumapiapp_v1_1
|
||||||
|
|
||||||
[filter:authN]
|
[filter:authN]
|
||||||
paste.filter_factory = keystone.middleware.quantum_auth_token:filter_factory
|
paste.filter_factory = keystone.middleware.quantum_auth_token:filter_factory
|
||||||
@ -46,5 +54,8 @@ paste.filter_factory = quantum.common.extensions:plugin_aware_extension_middlewa
|
|||||||
[app:quantumversions]
|
[app:quantumversions]
|
||||||
paste.app_factory = quantum.api.versions:Versions.factory
|
paste.app_factory = quantum.api.versions:Versions.factory
|
||||||
|
|
||||||
[app:quantumapiapp]
|
[app:quantumapiapp_v1_0]
|
||||||
paste.app_factory = quantum.api:APIRouterV1.factory
|
paste.app_factory = quantum.api:APIRouterV10.factory
|
||||||
|
|
||||||
|
[app:quantumapiapp_v1_1]
|
||||||
|
paste.app_factory = quantum.api:APIRouterV11.factory
|
||||||
|
@ -25,7 +25,6 @@ import webob.dec
|
|||||||
import webob.exc
|
import webob.exc
|
||||||
|
|
||||||
from quantum import manager
|
from quantum import manager
|
||||||
from quantum.api import faults
|
|
||||||
from quantum.api import attachments
|
from quantum.api import attachments
|
||||||
from quantum.api import networks
|
from quantum.api import networks
|
||||||
from quantum.api import ports
|
from quantum.api import ports
|
||||||
@ -37,34 +36,43 @@ LOG = logging.getLogger('quantum.api')
|
|||||||
FLAGS = flags.FLAGS
|
FLAGS = flags.FLAGS
|
||||||
|
|
||||||
|
|
||||||
class APIRouterV1(wsgi.Router):
|
class APIRouter(wsgi.Router):
|
||||||
"""
|
"""
|
||||||
Routes requests on the Quantum API to the appropriate controller
|
Base class for Quantum API routes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, options=None):
|
def __init__(self, options=None):
|
||||||
mapper = routes.Mapper()
|
mapper = self._mapper()
|
||||||
self._setup_routes(mapper, options)
|
self._setup_routes(mapper, options)
|
||||||
super(APIRouterV1, self).__init__(mapper)
|
super(APIRouter, self).__init__(mapper)
|
||||||
|
|
||||||
|
def _mapper(self):
|
||||||
|
return routes.Mapper()
|
||||||
|
|
||||||
def _setup_routes(self, mapper, options):
|
def _setup_routes(self, mapper, options):
|
||||||
|
self._setup_base_routes(mapper, options, self._version)
|
||||||
|
|
||||||
|
def _setup_base_routes(self, mapper, options, version):
|
||||||
|
"""Routes common to all versions."""
|
||||||
# Loads the quantum plugin
|
# Loads the quantum plugin
|
||||||
|
# Note(salvatore-orlando): Should the plugin be versioned
|
||||||
|
# I don't think so
|
||||||
plugin = manager.QuantumManager.get_plugin(options)
|
plugin = manager.QuantumManager.get_plugin(options)
|
||||||
|
|
||||||
uri_prefix = '/tenants/{tenant_id}/'
|
uri_prefix = '/tenants/{tenant_id}/'
|
||||||
mapper.resource('network', 'networks',
|
mapper.resource('network', 'networks',
|
||||||
controller=networks.Controller(plugin),
|
controller=networks.create_resource(plugin, version),
|
||||||
collection={'detail': 'GET'},
|
collection={'detail': 'GET'},
|
||||||
member={'detail': 'GET'},
|
member={'detail': 'GET'},
|
||||||
path_prefix=uri_prefix)
|
path_prefix=uri_prefix)
|
||||||
mapper.resource('port', 'ports',
|
mapper.resource('port', 'ports',
|
||||||
controller=ports.Controller(plugin),
|
controller=ports.create_resource(plugin, version),
|
||||||
collection={'detail': 'GET'},
|
collection={'detail': 'GET'},
|
||||||
member={'detail': 'GET'},
|
member={'detail': 'GET'},
|
||||||
parent_resource=dict(member_name='network',
|
parent_resource=dict(member_name='network',
|
||||||
collection_name=uri_prefix +\
|
collection_name=uri_prefix +\
|
||||||
'networks'))
|
'networks'))
|
||||||
attachments_ctrl = attachments.Controller(plugin)
|
attachments_ctrl = attachments.create_resource(plugin, version)
|
||||||
mapper.connect("get_resource",
|
mapper.connect("get_resource",
|
||||||
uri_prefix + 'networks/{network_id}/' \
|
uri_prefix + 'networks/{network_id}/' \
|
||||||
'ports/{id}/attachment{.format}',
|
'ports/{id}/attachment{.format}',
|
||||||
@ -83,3 +91,17 @@ class APIRouterV1(wsgi.Router):
|
|||||||
controller=attachments_ctrl,
|
controller=attachments_ctrl,
|
||||||
action="detach_resource",
|
action="detach_resource",
|
||||||
conditions=dict(method=['DELETE']))
|
conditions=dict(method=['DELETE']))
|
||||||
|
|
||||||
|
|
||||||
|
class APIRouterV10(APIRouter):
|
||||||
|
"""
|
||||||
|
API routes mappings for Quantum API v1.0
|
||||||
|
"""
|
||||||
|
_version = '1.0'
|
||||||
|
|
||||||
|
|
||||||
|
class APIRouterV11(APIRouter):
|
||||||
|
"""
|
||||||
|
API routes mappings for Quantum API v1.1
|
||||||
|
"""
|
||||||
|
_version = '1.1'
|
||||||
|
@ -16,39 +16,122 @@
|
|||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import webob
|
|
||||||
|
|
||||||
from webob import exc
|
from webob import exc
|
||||||
|
|
||||||
from quantum import wsgi
|
from quantum import wsgi
|
||||||
|
from quantum.api import faults
|
||||||
|
|
||||||
XML_NS_V01 = 'http://netstack.org/quantum/api/v0.1'
|
XML_NS_V10 = 'http://openstack.org/quantum/api/v1.0'
|
||||||
XML_NS_V10 = 'http://netstack.org/quantum/api/v1.0'
|
XML_NS_V11 = 'http://openstack.org/quantum/api/v1.1'
|
||||||
LOG = logging.getLogger('quantum.api.api_common')
|
LOG = logging.getLogger('quantum.api.api_common')
|
||||||
|
|
||||||
|
|
||||||
class QuantumController(wsgi.Controller):
|
def create_resource(version, controller_dict):
|
||||||
|
"""
|
||||||
|
Generic function for creating a wsgi resource
|
||||||
|
The function takes as input:
|
||||||
|
- desired version
|
||||||
|
- controller and metadata dictionary
|
||||||
|
e.g.: {'1.0': [ctrl_v10, meta_v10, xml_ns],
|
||||||
|
'1.1': [ctrl_v11, meta_v11, xml_ns]}
|
||||||
|
|
||||||
|
"""
|
||||||
|
# the first element of the iterable is expected to be the controller
|
||||||
|
controller = controller_dict[version][0]
|
||||||
|
# the second element should be the metadata
|
||||||
|
metadata = controller_dict[version][1]
|
||||||
|
# and the third element the xml namespace
|
||||||
|
xmlns = controller_dict[version][2]
|
||||||
|
|
||||||
|
headers_serializer = HeaderSerializer()
|
||||||
|
xml_serializer = wsgi.XMLDictSerializer(metadata, xmlns)
|
||||||
|
json_serializer = wsgi.JSONDictSerializer()
|
||||||
|
xml_deserializer = wsgi.XMLDeserializer(metadata)
|
||||||
|
json_deserializer = wsgi.JSONDeserializer()
|
||||||
|
|
||||||
|
body_serializers = {
|
||||||
|
'application/xml': xml_serializer,
|
||||||
|
'application/json': json_serializer,
|
||||||
|
}
|
||||||
|
|
||||||
|
body_deserializers = {
|
||||||
|
'application/xml': xml_deserializer,
|
||||||
|
'application/json': json_deserializer,
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = wsgi.ResponseSerializer(body_serializers, headers_serializer)
|
||||||
|
deserializer = wsgi.RequestDeserializer(body_deserializers)
|
||||||
|
|
||||||
|
return wsgi.Resource(controller, deserializer, serializer)
|
||||||
|
|
||||||
|
|
||||||
|
def APIFaultWrapper(errors=None):
|
||||||
|
|
||||||
|
def wrapper(func, **kwargs):
|
||||||
|
|
||||||
|
def the_func(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
if errors != None and type(e) in errors:
|
||||||
|
raise faults.QuantumHTTPError(e)
|
||||||
|
# otherwise just re-raise
|
||||||
|
raise
|
||||||
|
the_func.__name__ = func.__name__
|
||||||
|
return the_func
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class HeaderSerializer(wsgi.ResponseHeaderSerializer):
|
||||||
|
"""
|
||||||
|
Defines default respone status codes for Quantum API operations
|
||||||
|
create - 202 ACCEPTED
|
||||||
|
update - 204 NOCONTENT
|
||||||
|
delete - 204 NOCONTENT
|
||||||
|
others - 200 OK (defined in base class)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def create(self, response, data):
|
||||||
|
response.status_int = 202
|
||||||
|
|
||||||
|
def delete(self, response, data):
|
||||||
|
response.status_int = 204
|
||||||
|
|
||||||
|
def update(self, response, data):
|
||||||
|
response.status_int = 204
|
||||||
|
|
||||||
|
def attach_resource(self, response, data):
|
||||||
|
response.status_int = 204
|
||||||
|
|
||||||
|
def detach_resource(self, response, data):
|
||||||
|
response.status_int = 204
|
||||||
|
|
||||||
|
|
||||||
|
class QuantumController(object):
|
||||||
""" Base controller class for Quantum API """
|
""" Base controller class for Quantum API """
|
||||||
|
|
||||||
def __init__(self, plugin):
|
def __init__(self, plugin):
|
||||||
self._plugin = plugin
|
self._plugin = plugin
|
||||||
super(QuantumController, self).__init__()
|
super(QuantumController, self).__init__()
|
||||||
|
|
||||||
def _parse_request_params(self, req, params):
|
def _prepare_request_body(self, body, params):
|
||||||
results = {}
|
""" verifies required parameters are in request body.
|
||||||
data = {}
|
sets default value for missing optional parameters.
|
||||||
# Parameters are expected to be in request body only
|
|
||||||
if req.body:
|
|
||||||
des_body = self._deserialize(req.body,
|
|
||||||
req.best_match_content_type())
|
|
||||||
data = des_body and des_body.get(self._resource_name, None)
|
|
||||||
if not data:
|
|
||||||
msg = ("Failed to parse request. Resource: " +
|
|
||||||
self._resource_name + " not found in request body")
|
|
||||||
for line in msg.split('\n'):
|
|
||||||
LOG.error(line)
|
|
||||||
raise exc.HTTPBadRequest(msg)
|
|
||||||
|
|
||||||
|
body argument must be the deserialized body
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if body is None:
|
||||||
|
# Initialize empty resource for setting default value
|
||||||
|
body = {self._resource_name: {}}
|
||||||
|
data = body[self._resource_name]
|
||||||
|
except KeyError:
|
||||||
|
# raise if _resource_name is not in req body.
|
||||||
|
raise exc.HTTPBadRequest("Unable to find '%s' in request body"\
|
||||||
|
% self._resource_name)
|
||||||
for param in params:
|
for param in params:
|
||||||
param_name = param['param-name']
|
param_name = param['param-name']
|
||||||
param_value = data.get(param_name, None)
|
param_value = data.get(param_name, None)
|
||||||
@ -59,31 +142,5 @@ class QuantumController(wsgi.Controller):
|
|||||||
for line in msg.split('\n'):
|
for line in msg.split('\n'):
|
||||||
LOG.error(line)
|
LOG.error(line)
|
||||||
raise exc.HTTPBadRequest(msg)
|
raise exc.HTTPBadRequest(msg)
|
||||||
results[param_name] = param_value or param.get('default-value')
|
data[param_name] = param_value or param.get('default-value')
|
||||||
|
return body
|
||||||
# There may be other parameters (data extensions), so we
|
|
||||||
# should include those in the results dict as well.
|
|
||||||
for key in data.keys():
|
|
||||||
if key not in params:
|
|
||||||
results[key] = data[key]
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
def _build_response(self, req, res_data, status_code=200):
|
|
||||||
""" A function which builds an HTTP response
|
|
||||||
given a status code and a dictionary containing
|
|
||||||
the response body to be serialized
|
|
||||||
|
|
||||||
"""
|
|
||||||
content_type = req.best_match_content_type()
|
|
||||||
default_xmlns = self.get_default_xmlns(req)
|
|
||||||
body = self._serialize(res_data, content_type, default_xmlns)
|
|
||||||
|
|
||||||
response = webob.Response()
|
|
||||||
response.status = status_code
|
|
||||||
response.headers['Content-Type'] = content_type
|
|
||||||
response.body = body
|
|
||||||
msg_dict = dict(url=req.url, status=response.status_int)
|
|
||||||
msg = _("%(url)s returned with HTTP %(status)d") % msg_dict
|
|
||||||
LOG.debug(msg)
|
|
||||||
return response
|
|
||||||
|
@ -15,16 +15,25 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from webob import exc
|
|
||||||
|
|
||||||
from quantum.api import api_common as common
|
from quantum.api import api_common as common
|
||||||
from quantum.api import faults
|
|
||||||
from quantum.api.views import attachments as attachments_view
|
from quantum.api.views import attachments as attachments_view
|
||||||
from quantum.common import exceptions as exception
|
from quantum.common import exceptions as exception
|
||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger('quantum.api.ports')
|
LOG = logging.getLogger('quantum.api.ports')
|
||||||
|
|
||||||
|
|
||||||
|
def create_resource(plugin, version):
|
||||||
|
controller_dict = {
|
||||||
|
'1.0': [ControllerV10(plugin),
|
||||||
|
ControllerV10._serialization_metadata,
|
||||||
|
common.XML_NS_V10],
|
||||||
|
'1.1': [ControllerV11(plugin),
|
||||||
|
ControllerV11._serialization_metadata,
|
||||||
|
common.XML_NS_V11]}
|
||||||
|
return common.create_resource(version, controller_dict)
|
||||||
|
|
||||||
|
|
||||||
class Controller(common.QuantumController):
|
class Controller(common.QuantumController):
|
||||||
""" Port API controller for Quantum API """
|
""" Port API controller for Quantum API """
|
||||||
|
|
||||||
@ -43,45 +52,43 @@ class Controller(common.QuantumController):
|
|||||||
self._resource_name = 'attachment'
|
self._resource_name = 'attachment'
|
||||||
super(Controller, self).__init__(plugin)
|
super(Controller, self).__init__(plugin)
|
||||||
|
|
||||||
|
@common.APIFaultWrapper([exception.NetworkNotFound,
|
||||||
|
exception.PortNotFound])
|
||||||
def get_resource(self, request, tenant_id, network_id, id):
|
def get_resource(self, request, tenant_id, network_id, id):
|
||||||
try:
|
att_data = self._plugin.get_port_details(
|
||||||
att_data = self._plugin.get_port_details(
|
tenant_id, network_id, id)
|
||||||
tenant_id, network_id, id)
|
builder = attachments_view.get_view_builder(request)
|
||||||
builder = attachments_view.get_view_builder(request)
|
result = builder.build(att_data)['attachment']
|
||||||
result = builder.build(att_data)['attachment']
|
return dict(attachment=result)
|
||||||
return dict(attachment=result)
|
|
||||||
except exception.NetworkNotFound as e:
|
|
||||||
return faults.Fault(faults.NetworkNotFound(e))
|
|
||||||
except exception.PortNotFound as e:
|
|
||||||
return faults.Fault(faults.PortNotFound(e))
|
|
||||||
|
|
||||||
def attach_resource(self, request, tenant_id, network_id, id):
|
@common.APIFaultWrapper([exception.NetworkNotFound,
|
||||||
try:
|
exception.PortNotFound,
|
||||||
request_params = \
|
exception.PortInUse,
|
||||||
self._parse_request_params(request,
|
exception.AlreadyAttached])
|
||||||
self._attachment_ops_param_list)
|
def attach_resource(self, request, tenant_id, network_id, id, body):
|
||||||
except exc.HTTPError as e:
|
body = self._prepare_request_body(body,
|
||||||
return faults.Fault(e)
|
self._attachment_ops_param_list)
|
||||||
try:
|
self._plugin.plug_interface(tenant_id, network_id, id,
|
||||||
LOG.debug("PLUGGING INTERFACE:%s", request_params['id'])
|
body['attachment']['id'])
|
||||||
self._plugin.plug_interface(tenant_id, network_id, id,
|
|
||||||
request_params['id'])
|
|
||||||
return exc.HTTPNoContent()
|
|
||||||
except exception.NetworkNotFound as e:
|
|
||||||
return faults.Fault(faults.NetworkNotFound(e))
|
|
||||||
except exception.PortNotFound as e:
|
|
||||||
return faults.Fault(faults.PortNotFound(e))
|
|
||||||
except exception.PortInUse as e:
|
|
||||||
return faults.Fault(faults.PortInUse(e))
|
|
||||||
except exception.AlreadyAttached as e:
|
|
||||||
return faults.Fault(faults.AlreadyAttached(e))
|
|
||||||
|
|
||||||
|
@common.APIFaultWrapper([exception.NetworkNotFound,
|
||||||
|
exception.PortNotFound])
|
||||||
def detach_resource(self, request, tenant_id, network_id, id):
|
def detach_resource(self, request, tenant_id, network_id, id):
|
||||||
try:
|
self._plugin.unplug_interface(tenant_id,
|
||||||
self._plugin.unplug_interface(tenant_id,
|
network_id, id)
|
||||||
network_id, id)
|
|
||||||
return exc.HTTPNoContent()
|
|
||||||
except exception.NetworkNotFound as e:
|
class ControllerV10(Controller):
|
||||||
return faults.Fault(faults.NetworkNotFound(e))
|
"""Attachment resources controller for Quantum v1.0 API"""
|
||||||
except exception.PortNotFound as e:
|
|
||||||
return faults.Fault(faults.PortNotFound(e))
|
def __init__(self, plugin):
|
||||||
|
self.version = "1.0"
|
||||||
|
super(ControllerV10, self).__init__(plugin)
|
||||||
|
|
||||||
|
|
||||||
|
class ControllerV11(Controller):
|
||||||
|
"""Attachment resources controller for Quantum v1.1 API"""
|
||||||
|
|
||||||
|
def __init__(self, plugin):
|
||||||
|
self.version = "1.1"
|
||||||
|
super(ControllerV11, self).__init__(plugin)
|
||||||
|
@ -16,132 +16,57 @@
|
|||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
|
||||||
import webob.dec
|
|
||||||
import webob.exc
|
import webob.exc
|
||||||
|
|
||||||
from quantum.api import api_common as common
|
from quantum.common import exceptions
|
||||||
from quantum import wsgi
|
|
||||||
|
_NETNOTFOUND_EXPL = 'Unable to find a network with the specified identifier.'
|
||||||
|
_NETINUSE_EXPL = 'Unable to remove the network: attachments still plugged.'
|
||||||
|
_PORTNOTFOUND_EXPL = 'Unable to find a port with the specified identifier.'
|
||||||
|
_STATEINVALID_EXPL = 'Unable to update port state with specified value.'
|
||||||
|
_PORTINUSE_EXPL = 'A resource is currently attached to the logical port'
|
||||||
|
_ALREADYATTACHED_EXPL = 'The resource is already attached to another port'
|
||||||
|
|
||||||
|
|
||||||
class Fault(webob.exc.HTTPException):
|
class QuantumHTTPError(webob.exc.HTTPClientError):
|
||||||
"""Error codes for API faults"""
|
|
||||||
|
|
||||||
_fault_names = {
|
_fault_dict = {
|
||||||
400: "malformedRequest",
|
exceptions.NetworkNotFound: {
|
||||||
401: "unauthorized",
|
'code': 420,
|
||||||
420: "networkNotFound",
|
'title': 'networkNotFound',
|
||||||
421: "networkInUse",
|
'explanation': _NETNOTFOUND_EXPL
|
||||||
430: "portNotFound",
|
},
|
||||||
431: "requestedStateInvalid",
|
exceptions.NetworkInUse: {
|
||||||
432: "portInUse",
|
'code': 421,
|
||||||
440: "alreadyAttached",
|
'title': 'networkInUse',
|
||||||
470: "serviceUnavailable",
|
'explanation': _NETINUSE_EXPL
|
||||||
471: "pluginFault"}
|
},
|
||||||
|
exceptions.PortNotFound: {
|
||||||
|
'code': 430,
|
||||||
|
'title': 'portNotFound',
|
||||||
|
'explanation': _PORTNOTFOUND_EXPL
|
||||||
|
},
|
||||||
|
exceptions.StateInvalid: {
|
||||||
|
'code': 431,
|
||||||
|
'title': 'requestedStateInvalid',
|
||||||
|
'explanation': _STATEINVALID_EXPL
|
||||||
|
},
|
||||||
|
exceptions.PortInUse: {
|
||||||
|
'code': 432,
|
||||||
|
'title': 'portInUse',
|
||||||
|
'explanation': _PORTINUSE_EXPL
|
||||||
|
},
|
||||||
|
exceptions.AlreadyAttached: {
|
||||||
|
'code': 440,
|
||||||
|
'title': 'alreadyAttached',
|
||||||
|
'explanation': _ALREADYATTACHED_EXPL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, exception):
|
def __init__(self, inner_exc):
|
||||||
"""Create a Fault for the given webob.exc.exception."""
|
_fault_data = self._fault_dict.get(type(inner_exc), None)
|
||||||
self.wrapped_exc = exception
|
if _fault_data:
|
||||||
|
self.code = _fault_data['code']
|
||||||
@webob.dec.wsgify(RequestClass=wsgi.Request)
|
self.title = _fault_data['title']
|
||||||
def __call__(self, req):
|
self.explanation = _fault_data['explanation']
|
||||||
"""Generate a WSGI response based on the exception passed to ctor."""
|
super(webob.exc.HTTPClientError, self).__init__(inner_exc)
|
||||||
# Replace the body with fault details.
|
|
||||||
code = self.wrapped_exc.status_int
|
|
||||||
fault_name = self._fault_names.get(code, "quantumServiceFault")
|
|
||||||
fault_data = {
|
|
||||||
fault_name: {
|
|
||||||
'code': code,
|
|
||||||
'message': self.wrapped_exc.explanation,
|
|
||||||
'detail': str(self.wrapped_exc.detail)}}
|
|
||||||
# 'code' is an attribute on the fault tag itself
|
|
||||||
metadata = {'application/xml': {'attributes': {fault_name: 'code'}}}
|
|
||||||
default_xmlns = common.XML_NS_V10
|
|
||||||
serializer = wsgi.Serializer(metadata, default_xmlns)
|
|
||||||
content_type = req.best_match_content_type()
|
|
||||||
self.wrapped_exc.body = serializer.serialize(fault_data, content_type)
|
|
||||||
self.wrapped_exc.content_type = content_type
|
|
||||||
return self.wrapped_exc
|
|
||||||
|
|
||||||
|
|
||||||
class NetworkNotFound(webob.exc.HTTPClientError):
|
|
||||||
"""
|
|
||||||
subclass of :class:`~HTTPClientError`
|
|
||||||
|
|
||||||
This indicates that the server did not find the network specified
|
|
||||||
in the HTTP request
|
|
||||||
|
|
||||||
code: 420, title: Network not Found
|
|
||||||
"""
|
|
||||||
code = 420
|
|
||||||
title = 'Network not Found'
|
|
||||||
explanation = ('Unable to find a network with the specified identifier.')
|
|
||||||
|
|
||||||
|
|
||||||
class NetworkInUse(webob.exc.HTTPClientError):
|
|
||||||
"""
|
|
||||||
subclass of :class:`~HTTPClientError`
|
|
||||||
|
|
||||||
This indicates that the server could not delete the network as there is
|
|
||||||
at least an attachment plugged into its ports
|
|
||||||
|
|
||||||
code: 421, title: Network In Use
|
|
||||||
"""
|
|
||||||
code = 421
|
|
||||||
title = 'Network in Use'
|
|
||||||
explanation = ('Unable to remove the network: attachments still plugged.')
|
|
||||||
|
|
||||||
|
|
||||||
class PortNotFound(webob.exc.HTTPClientError):
|
|
||||||
"""
|
|
||||||
subclass of :class:`~HTTPClientError`
|
|
||||||
|
|
||||||
This indicates that the server did not find the port specified
|
|
||||||
in the HTTP request for a given network
|
|
||||||
|
|
||||||
code: 430, title: Port not Found
|
|
||||||
"""
|
|
||||||
code = 430
|
|
||||||
title = 'Port not Found'
|
|
||||||
explanation = ('Unable to find a port with the specified identifier.')
|
|
||||||
|
|
||||||
|
|
||||||
class RequestedStateInvalid(webob.exc.HTTPClientError):
|
|
||||||
"""
|
|
||||||
subclass of :class:`~HTTPClientError`
|
|
||||||
|
|
||||||
This indicates that the server could not update the port state to
|
|
||||||
to the request value
|
|
||||||
|
|
||||||
code: 431, title: Requested State Invalid
|
|
||||||
"""
|
|
||||||
code = 431
|
|
||||||
title = 'Requested State Invalid'
|
|
||||||
explanation = ('Unable to update port state with specified value.')
|
|
||||||
|
|
||||||
|
|
||||||
class PortInUse(webob.exc.HTTPClientError):
|
|
||||||
"""
|
|
||||||
subclass of :class:`~HTTPClientError`
|
|
||||||
|
|
||||||
This indicates that the server could not remove o port or attach
|
|
||||||
a resource to it because there is an attachment plugged into the port
|
|
||||||
|
|
||||||
code: 432, title: PortInUse
|
|
||||||
"""
|
|
||||||
code = 432
|
|
||||||
title = 'Port in Use'
|
|
||||||
explanation = ('A resource is currently attached to the logical port')
|
|
||||||
|
|
||||||
|
|
||||||
class AlreadyAttached(webob.exc.HTTPClientError):
|
|
||||||
"""
|
|
||||||
subclass of :class:`~HTTPClientError`
|
|
||||||
|
|
||||||
This indicates that the server refused an attempt to re-attach a resource
|
|
||||||
already attached to the network
|
|
||||||
|
|
||||||
code: 440, title: AlreadyAttached
|
|
||||||
"""
|
|
||||||
code = 440
|
|
||||||
title = 'Already Attached'
|
|
||||||
explanation = ('The resource is already attached to another port')
|
|
||||||
|
@ -25,6 +25,17 @@ from quantum.common import exceptions as exception
|
|||||||
LOG = logging.getLogger('quantum.api.networks')
|
LOG = logging.getLogger('quantum.api.networks')
|
||||||
|
|
||||||
|
|
||||||
|
def create_resource(plugin, version):
|
||||||
|
controller_dict = {
|
||||||
|
'1.0': [ControllerV10(plugin),
|
||||||
|
ControllerV10._serialization_metadata,
|
||||||
|
common.XML_NS_V10],
|
||||||
|
'1.1': [ControllerV11(plugin),
|
||||||
|
ControllerV11._serialization_metadata,
|
||||||
|
common.XML_NS_V11]}
|
||||||
|
return common.create_resource(version, controller_dict)
|
||||||
|
|
||||||
|
|
||||||
class Controller(common.QuantumController):
|
class Controller(common.QuantumController):
|
||||||
""" Network API controller for Quantum API """
|
""" Network API controller for Quantum API """
|
||||||
|
|
||||||
@ -32,21 +43,11 @@ class Controller(common.QuantumController):
|
|||||||
'param-name': 'name',
|
'param-name': 'name',
|
||||||
'required': True}, ]
|
'required': True}, ]
|
||||||
|
|
||||||
_serialization_metadata = {
|
|
||||||
"application/xml": {
|
|
||||||
"attributes": {
|
|
||||||
"network": ["id", "name"],
|
|
||||||
"port": ["id", "state"],
|
|
||||||
"attachment": ["id"]},
|
|
||||||
"plurals": {"networks": "network",
|
|
||||||
"ports": "port"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, plugin):
|
def __init__(self, plugin):
|
||||||
self._resource_name = 'network'
|
self._resource_name = 'network'
|
||||||
super(Controller, self).__init__(plugin)
|
super(Controller, self).__init__(plugin)
|
||||||
|
|
||||||
def _item(self, req, tenant_id, network_id,
|
def _item(self, request, tenant_id, network_id,
|
||||||
net_details=True, port_details=False):
|
net_details=True, port_details=False):
|
||||||
# We expect get_network_details to return information
|
# We expect get_network_details to return information
|
||||||
# concerning logical ports as well.
|
# concerning logical ports as well.
|
||||||
@ -57,31 +58,34 @@ class Controller(common.QuantumController):
|
|||||||
ports_data = [self._plugin.get_port_details(
|
ports_data = [self._plugin.get_port_details(
|
||||||
tenant_id, network_id, port['port-id'])
|
tenant_id, network_id, port['port-id'])
|
||||||
for port in port_list]
|
for port in port_list]
|
||||||
builder = networks_view.get_view_builder(req)
|
builder = networks_view.get_view_builder(request, self.version)
|
||||||
result = builder.build(network, net_details,
|
result = builder.build(network, net_details,
|
||||||
ports_data, port_details)['network']
|
ports_data, port_details)['network']
|
||||||
return dict(network=result)
|
return dict(network=result)
|
||||||
|
|
||||||
def _items(self, req, tenant_id, net_details=False):
|
def _items(self, request, tenant_id, net_details=False):
|
||||||
""" Returns a list of networks. """
|
""" Returns a list of networks. """
|
||||||
networks = self._plugin.get_all_networks(tenant_id)
|
networks = self._plugin.get_all_networks(tenant_id)
|
||||||
builder = networks_view.get_view_builder(req)
|
builder = networks_view.get_view_builder(request, self.version)
|
||||||
result = [builder.build(network, net_details)['network']
|
result = [builder.build(network, net_details)['network']
|
||||||
for network in networks]
|
for network in networks]
|
||||||
return dict(networks=result)
|
return dict(networks=result)
|
||||||
|
|
||||||
|
@common.APIFaultWrapper()
|
||||||
def index(self, request, tenant_id):
|
def index(self, request, tenant_id):
|
||||||
""" Returns a list of network ids """
|
""" Returns a list of network ids """
|
||||||
return self._items(request, tenant_id)
|
return self._items(request, tenant_id)
|
||||||
|
|
||||||
|
@common.APIFaultWrapper()
|
||||||
def show(self, request, tenant_id, id):
|
def show(self, request, tenant_id, id):
|
||||||
""" Returns network details for the given network id """
|
""" Returns network details for the given network id """
|
||||||
try:
|
try:
|
||||||
return self._item(request, tenant_id, id,
|
return self._item(request, tenant_id, id,
|
||||||
net_details=True, port_details=False)
|
net_details=True, port_details=False)
|
||||||
except exception.NetworkNotFound as e:
|
except exception.NetworkNotFound as e:
|
||||||
return faults.Fault(faults.NetworkNotFound(e))
|
raise faults.QuantumHTTPError(e)
|
||||||
|
|
||||||
|
@common.APIFaultWrapper()
|
||||||
def detail(self, request, **kwargs):
|
def detail(self, request, **kwargs):
|
||||||
tenant_id = kwargs.get('tenant_id')
|
tenant_id = kwargs.get('tenant_id')
|
||||||
network_id = kwargs.get('id')
|
network_id = kwargs.get('id')
|
||||||
@ -93,53 +97,66 @@ class Controller(common.QuantumController):
|
|||||||
# show details for all networks
|
# show details for all networks
|
||||||
return self._items(request, tenant_id, net_details=True)
|
return self._items(request, tenant_id, net_details=True)
|
||||||
|
|
||||||
def create(self, request, tenant_id):
|
@common.APIFaultWrapper()
|
||||||
|
def create(self, request, tenant_id, body):
|
||||||
""" Creates a new network for a given tenant """
|
""" Creates a new network for a given tenant """
|
||||||
try:
|
|
||||||
request_params = \
|
|
||||||
self._parse_request_params(request,
|
|
||||||
self._network_ops_param_list)
|
|
||||||
except exc.HTTPError as e:
|
|
||||||
return faults.Fault(e)
|
|
||||||
# NOTE(bgh): We're currently passing both request_params['name'] and
|
# NOTE(bgh): We're currently passing both request_params['name'] and
|
||||||
# the entire request_params dict because their may be pieces of
|
# the entire request_params dict because their may be pieces of
|
||||||
# information (data extensions) inside the request params that the
|
# information (data extensions) inside the request params that the
|
||||||
# actual plugin will want to parse. We could just pass only
|
# actual plugin will want to parse. We could just pass only
|
||||||
# request_params but that would mean all the plugins would need to
|
# request_params but that would mean all the plugins would need to
|
||||||
# change.
|
# change.
|
||||||
|
body = self._prepare_request_body(body, self._network_ops_param_list)
|
||||||
network = self._plugin.\
|
network = self._plugin.\
|
||||||
create_network(tenant_id,
|
create_network(tenant_id,
|
||||||
request_params['name'],
|
body['network']['name'],
|
||||||
**request_params)
|
**body)
|
||||||
builder = networks_view.get_view_builder(request)
|
builder = networks_view.get_view_builder(request, self.version)
|
||||||
result = builder.build(network)['network']
|
result = builder.build(network)['network']
|
||||||
# Wsgi middleware allows us to build the response
|
return dict(network=result)
|
||||||
# before returning the call.
|
|
||||||
# This will allow us to return a 200 status code. NOTE: in v1.1 we
|
|
||||||
# will be returning a 202 status code.
|
|
||||||
return self._build_response(request, dict(network=result), 200)
|
|
||||||
|
|
||||||
def update(self, request, tenant_id, id):
|
@common.APIFaultWrapper([exception.NetworkNotFound])
|
||||||
|
def update(self, request, tenant_id, id, body):
|
||||||
""" Updates the name for the network with the given id """
|
""" Updates the name for the network with the given id """
|
||||||
try:
|
body = self._prepare_request_body(body, self._network_ops_param_list)
|
||||||
request_params = \
|
self._plugin.update_network(tenant_id, id, **body['network'])
|
||||||
self._parse_request_params(request,
|
|
||||||
self._network_ops_param_list)
|
|
||||||
except exc.HTTPError as e:
|
|
||||||
return faults.Fault(e)
|
|
||||||
try:
|
|
||||||
self._plugin.update_network(tenant_id, id,
|
|
||||||
**request_params)
|
|
||||||
return exc.HTTPNoContent()
|
|
||||||
except exception.NetworkNotFound as e:
|
|
||||||
return faults.Fault(faults.NetworkNotFound(e))
|
|
||||||
|
|
||||||
|
@common.APIFaultWrapper([exception.NetworkNotFound,
|
||||||
|
exception.NetworkInUse])
|
||||||
def delete(self, request, tenant_id, id):
|
def delete(self, request, tenant_id, id):
|
||||||
""" Destroys the network with the given id """
|
""" Destroys the network with the given id """
|
||||||
try:
|
self._plugin.delete_network(tenant_id, id)
|
||||||
self._plugin.delete_network(tenant_id, id)
|
|
||||||
return exc.HTTPNoContent()
|
|
||||||
except exception.NetworkNotFound as e:
|
class ControllerV10(Controller):
|
||||||
return faults.Fault(faults.NetworkNotFound(e))
|
"""Network resources controller for Quantum v1.0 API"""
|
||||||
except exception.NetworkInUse as e:
|
|
||||||
return faults.Fault(faults.NetworkInUse(e))
|
_serialization_metadata = {
|
||||||
|
"attributes": {
|
||||||
|
"network": ["id", "name"],
|
||||||
|
"port": ["id", "state"],
|
||||||
|
"attachment": ["id"]},
|
||||||
|
"plurals": {"networks": "network",
|
||||||
|
"ports": "port"}
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, plugin):
|
||||||
|
self.version = "1.0"
|
||||||
|
super(ControllerV10, self).__init__(plugin)
|
||||||
|
|
||||||
|
|
||||||
|
class ControllerV11(Controller):
|
||||||
|
"""Network resources controller for Quantum v1.1 API"""
|
||||||
|
|
||||||
|
_serialization_metadata = {
|
||||||
|
"attributes": {
|
||||||
|
"network": ["id", "name"],
|
||||||
|
"port": ["id", "state"],
|
||||||
|
"attachment": ["id"]},
|
||||||
|
"plurals": {"networks": "network",
|
||||||
|
"ports": "port"}
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, plugin):
|
||||||
|
self.version = "1.1"
|
||||||
|
super(ControllerV11, self).__init__(plugin)
|
||||||
|
@ -15,16 +15,25 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from webob import exc
|
|
||||||
|
|
||||||
from quantum.api import api_common as common
|
from quantum.api import api_common as common
|
||||||
from quantum.api import faults
|
|
||||||
from quantum.api.views import ports as ports_view
|
from quantum.api.views import ports as ports_view
|
||||||
from quantum.common import exceptions as exception
|
from quantum.common import exceptions as exception
|
||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger('quantum.api.ports')
|
LOG = logging.getLogger('quantum.api.ports')
|
||||||
|
|
||||||
|
|
||||||
|
def create_resource(plugin, version):
|
||||||
|
controller_dict = {
|
||||||
|
'1.0': [ControllerV10(plugin),
|
||||||
|
ControllerV10._serialization_metadata,
|
||||||
|
common.XML_NS_V10],
|
||||||
|
'1.1': [ControllerV11(plugin),
|
||||||
|
ControllerV11._serialization_metadata,
|
||||||
|
common.XML_NS_V11]}
|
||||||
|
return common.create_resource(version, controller_dict)
|
||||||
|
|
||||||
|
|
||||||
class Controller(common.QuantumController):
|
class Controller(common.QuantumController):
|
||||||
""" Port API controller for Quantum API """
|
""" Port API controller for Quantum API """
|
||||||
|
|
||||||
@ -33,14 +42,6 @@ class Controller(common.QuantumController):
|
|||||||
'default-value': 'DOWN',
|
'default-value': 'DOWN',
|
||||||
'required': False}, ]
|
'required': False}, ]
|
||||||
|
|
||||||
_serialization_metadata = {
|
|
||||||
"application/xml": {
|
|
||||||
"attributes": {
|
|
||||||
"port": ["id", "state"],
|
|
||||||
"attachment": ["id"]},
|
|
||||||
"plurals": {"ports": "port"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, plugin):
|
def __init__(self, plugin):
|
||||||
self._resource_name = 'port'
|
self._resource_name = 'port'
|
||||||
super(Controller, self).__init__(plugin)
|
super(Controller, self).__init__(plugin)
|
||||||
@ -48,23 +49,20 @@ class Controller(common.QuantumController):
|
|||||||
def _items(self, request, tenant_id, network_id,
|
def _items(self, request, tenant_id, network_id,
|
||||||
port_details=False):
|
port_details=False):
|
||||||
""" Returns a list of ports. """
|
""" Returns a list of ports. """
|
||||||
try:
|
port_list = self._plugin.get_all_ports(tenant_id, network_id)
|
||||||
port_list = self._plugin.get_all_ports(tenant_id, network_id)
|
builder = ports_view.get_view_builder(request)
|
||||||
builder = ports_view.get_view_builder(request)
|
|
||||||
|
|
||||||
# Load extra data for ports if required.
|
# Load extra data for ports if required.
|
||||||
if port_details:
|
if port_details:
|
||||||
port_list_detail = \
|
port_list_detail = \
|
||||||
[self._plugin.get_port_details(
|
[self._plugin.get_port_details(
|
||||||
tenant_id, network_id, port['port-id'])
|
tenant_id, network_id, port['port-id'])
|
||||||
for port in port_list]
|
for port in port_list]
|
||||||
port_list = port_list_detail
|
port_list = port_list_detail
|
||||||
|
|
||||||
result = [builder.build(port, port_details)['port']
|
result = [builder.build(port, port_details)['port']
|
||||||
for port in port_list]
|
for port in port_list]
|
||||||
return dict(ports=result)
|
return dict(ports=result)
|
||||||
except exception.NetworkNotFound as e:
|
|
||||||
return faults.Fault(faults.NetworkNotFound(e))
|
|
||||||
|
|
||||||
def _item(self, request, tenant_id, network_id, port_id,
|
def _item(self, request, tenant_id, network_id, port_id,
|
||||||
att_details=False):
|
att_details=False):
|
||||||
@ -76,19 +74,19 @@ class Controller(common.QuantumController):
|
|||||||
att_details=att_details)['port']
|
att_details=att_details)['port']
|
||||||
return dict(port=result)
|
return dict(port=result)
|
||||||
|
|
||||||
|
@common.APIFaultWrapper([exception.NetworkNotFound])
|
||||||
def index(self, request, tenant_id, network_id):
|
def index(self, request, tenant_id, network_id):
|
||||||
""" Returns a list of port ids for a given network """
|
""" Returns a list of port ids for a given network """
|
||||||
return self._items(request, tenant_id, network_id, port_details=False)
|
return self._items(request, tenant_id, network_id, port_details=False)
|
||||||
|
|
||||||
|
@common.APIFaultWrapper([exception.NetworkNotFound,
|
||||||
|
exception.PortNotFound])
|
||||||
def show(self, request, tenant_id, network_id, id):
|
def show(self, request, tenant_id, network_id, id):
|
||||||
""" Returns port details for given port and network """
|
""" Returns port details for given port and network """
|
||||||
try:
|
return self._item(request, tenant_id, network_id, id)
|
||||||
return self._item(request, tenant_id, network_id, id)
|
|
||||||
except exception.NetworkNotFound as e:
|
|
||||||
return faults.Fault(faults.NetworkNotFound(e))
|
|
||||||
except exception.PortNotFound as e:
|
|
||||||
return faults.Fault(faults.PortNotFound(e))
|
|
||||||
|
|
||||||
|
@common.APIFaultWrapper([exception.NetworkNotFound,
|
||||||
|
exception.PortNotFound])
|
||||||
def detail(self, request, **kwargs):
|
def detail(self, request, **kwargs):
|
||||||
tenant_id = kwargs.get('tenant_id')
|
tenant_id = kwargs.get('tenant_id')
|
||||||
network_id = kwargs.get('network_id')
|
network_id = kwargs.get('network_id')
|
||||||
@ -102,57 +100,62 @@ class Controller(common.QuantumController):
|
|||||||
return self._items(request, tenant_id,
|
return self._items(request, tenant_id,
|
||||||
network_id, port_details=True)
|
network_id, port_details=True)
|
||||||
|
|
||||||
def create(self, request, tenant_id, network_id):
|
@common.APIFaultWrapper([exception.NetworkNotFound,
|
||||||
""" Creates a new port for a given network """
|
exception.StateInvalid])
|
||||||
try:
|
def create(self, request, tenant_id, network_id, body=None):
|
||||||
request_params = \
|
""" Creates a new port for a given network
|
||||||
self._parse_request_params(request, self._port_ops_param_list)
|
The request body is optional for a port object.
|
||||||
except exc.HTTPError as e:
|
|
||||||
return faults.Fault(e)
|
|
||||||
try:
|
|
||||||
port = self._plugin.create_port(tenant_id,
|
|
||||||
network_id,
|
|
||||||
request_params['state'],
|
|
||||||
**request_params)
|
|
||||||
builder = ports_view.get_view_builder(request)
|
|
||||||
result = builder.build(port)['port']
|
|
||||||
# Wsgi middleware allows us to build the response
|
|
||||||
# before returning the call.
|
|
||||||
# This will allow us to return a 200 status code. NOTE: in v1.1
|
|
||||||
# we will be returning a 202 status code.
|
|
||||||
return self._build_response(request, dict(port=result), 200)
|
|
||||||
except exception.NetworkNotFound as e:
|
|
||||||
return faults.Fault(faults.NetworkNotFound(e))
|
|
||||||
except exception.StateInvalid as e:
|
|
||||||
return faults.Fault(faults.RequestedStateInvalid(e))
|
|
||||||
|
|
||||||
def update(self, request, tenant_id, network_id, id):
|
"""
|
||||||
|
body = self._prepare_request_body(body, self._port_ops_param_list)
|
||||||
|
port = self._plugin.create_port(tenant_id,
|
||||||
|
network_id, body['port']['state'],
|
||||||
|
**body)
|
||||||
|
builder = ports_view.get_view_builder(request)
|
||||||
|
result = builder.build(port)['port']
|
||||||
|
return dict(port=result)
|
||||||
|
|
||||||
|
@common.APIFaultWrapper([exception.NetworkNotFound,
|
||||||
|
exception.PortNotFound,
|
||||||
|
exception.StateInvalid])
|
||||||
|
def update(self, request, tenant_id, network_id, id, body):
|
||||||
""" Updates the state of a port for a given network """
|
""" Updates the state of a port for a given network """
|
||||||
try:
|
body = self._prepare_request_body(body, self._port_ops_param_list)
|
||||||
request_params = \
|
self._plugin.update_port(tenant_id, network_id, id, **body['port'])
|
||||||
self._parse_request_params(request, self._port_ops_param_list)
|
|
||||||
except exc.HTTPError as e:
|
|
||||||
return faults.Fault(e)
|
|
||||||
try:
|
|
||||||
self._plugin.update_port(tenant_id, network_id, id,
|
|
||||||
**request_params)
|
|
||||||
return exc.HTTPNoContent()
|
|
||||||
except exception.NetworkNotFound as e:
|
|
||||||
return faults.Fault(faults.NetworkNotFound(e))
|
|
||||||
except exception.PortNotFound as e:
|
|
||||||
return faults.Fault(faults.PortNotFound(e))
|
|
||||||
except exception.StateInvalid as e:
|
|
||||||
return faults.Fault(faults.RequestedStateInvalid(e))
|
|
||||||
|
|
||||||
|
@common.APIFaultWrapper([exception.NetworkNotFound,
|
||||||
|
exception.PortNotFound,
|
||||||
|
exception.PortInUse])
|
||||||
def delete(self, request, tenant_id, network_id, id):
|
def delete(self, request, tenant_id, network_id, id):
|
||||||
""" Destroys the port with the given id """
|
""" Destroys the port with the given id """
|
||||||
#look for port state in request
|
self._plugin.delete_port(tenant_id, network_id, id)
|
||||||
try:
|
|
||||||
self._plugin.delete_port(tenant_id, network_id, id)
|
|
||||||
return exc.HTTPNoContent()
|
class ControllerV10(Controller):
|
||||||
except exception.NetworkNotFound as e:
|
"""Port resources controller for Quantum v1.0 API"""
|
||||||
return faults.Fault(faults.NetworkNotFound(e))
|
|
||||||
except exception.PortNotFound as e:
|
_serialization_metadata = {
|
||||||
return faults.Fault(faults.PortNotFound(e))
|
"attributes": {
|
||||||
except exception.PortInUse as e:
|
"port": ["id", "state"],
|
||||||
return faults.Fault(faults.PortInUse(e))
|
"attachment": ["id"]},
|
||||||
|
"plurals": {"ports": "port"}
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, plugin):
|
||||||
|
self.version = "1.0"
|
||||||
|
super(ControllerV10, self).__init__(plugin)
|
||||||
|
|
||||||
|
|
||||||
|
class ControllerV11(Controller):
|
||||||
|
"""Port resources controller for Quantum v1.1 API"""
|
||||||
|
|
||||||
|
_serialization_metadata = {
|
||||||
|
"attributes": {
|
||||||
|
"port": ["id", "state"],
|
||||||
|
"attachment": ["id"]},
|
||||||
|
"plurals": {"ports": "port"}
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, plugin):
|
||||||
|
self.version = "1.1"
|
||||||
|
super(ControllerV11, self).__init__(plugin)
|
||||||
|
@ -36,7 +36,7 @@ class Versions(wsgi.Application):
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "v1.1",
|
"id": "v1.1",
|
||||||
"status": "FUTURE",
|
"status": "PROPOSED",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -16,12 +16,16 @@
|
|||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
|
||||||
def get_view_builder(req):
|
def get_view_builder(req, version):
|
||||||
base_url = req.application_url
|
base_url = req.application_url
|
||||||
return ViewBuilder(base_url)
|
view_builder = {
|
||||||
|
'1.0': ViewBuilder10,
|
||||||
|
'1.1': ViewBuilder11,
|
||||||
|
}[version](base_url)
|
||||||
|
return view_builder
|
||||||
|
|
||||||
|
|
||||||
class ViewBuilder(object):
|
class ViewBuilder10(object):
|
||||||
|
|
||||||
def __init__(self, base_url=None):
|
def __init__(self, base_url=None):
|
||||||
"""
|
"""
|
||||||
@ -57,3 +61,9 @@ class ViewBuilder(object):
|
|||||||
if port_data['attachment']:
|
if port_data['attachment']:
|
||||||
port_dict['attachment'] = dict(id=port_data['attachment'])
|
port_dict['attachment'] = dict(id=port_data['attachment'])
|
||||||
return port_dict
|
return port_dict
|
||||||
|
|
||||||
|
|
||||||
|
class ViewBuilder11(ViewBuilder10):
|
||||||
|
#TODO(salvatore-orlando): will extend for Operational status
|
||||||
|
# in appropriate branch
|
||||||
|
pass
|
||||||
|
@ -126,6 +126,10 @@ class AlreadyAttachedClient(QuantumException):
|
|||||||
"already plugged into another port.")
|
"already plugged into another port.")
|
||||||
|
|
||||||
|
|
||||||
|
class MalformedRequestBody(QuantumException):
|
||||||
|
message = _("Malformed request body: %(reason)s")
|
||||||
|
|
||||||
|
|
||||||
class Duplicate(Error):
|
class Duplicate(Error):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -243,9 +243,11 @@ class QuantumTestResult(result.TextTestResult):
|
|||||||
if current_case != self._last_case:
|
if current_case != self._last_case:
|
||||||
self.stream.writeln(current_case)
|
self.stream.writeln(current_case)
|
||||||
self._last_case = current_case
|
self._last_case = current_case
|
||||||
|
#NOTE(salvatore-orlando):
|
||||||
|
#slightly changed in order to print test case class
|
||||||
|
#together with unit test name
|
||||||
self.stream.write(
|
self.stream.write(
|
||||||
' %s' % str(test.test._testMethodName).ljust(60))
|
' %s' % str(test.test).ljust(60))
|
||||||
self.stream.flush()
|
self.stream.flush()
|
||||||
|
|
||||||
|
|
||||||
|
@ -109,7 +109,7 @@ def _run_wsgi(app_name, paste_conf, paste_config_file):
|
|||||||
LOG.error(_('No known API applications configured in %s.'),
|
LOG.error(_('No known API applications configured in %s.'),
|
||||||
paste_config_file)
|
paste_config_file)
|
||||||
return
|
return
|
||||||
server = wsgi.Server()
|
server = wsgi.Server("Quantum")
|
||||||
server.start(app,
|
server.start(app,
|
||||||
int(paste_conf['bind_port']), paste_conf['bind_host'])
|
int(paste_conf['bind_port']), paste_conf['bind_host'])
|
||||||
return server
|
return server
|
||||||
|
1122
quantum/tests/unit/_test_api.py
Normal file
1122
quantum/tests/unit/_test_api.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -42,7 +42,7 @@ class FakeHTTPConnection:
|
|||||||
self._req = None
|
self._req = None
|
||||||
plugin = 'quantum.plugins.sample.SamplePlugin.FakePlugin'
|
plugin = 'quantum.plugins.sample.SamplePlugin.FakePlugin'
|
||||||
options = dict(plugin_provider=plugin)
|
options = dict(plugin_provider=plugin)
|
||||||
self._api = server.APIRouterV1(options)
|
self._api = server.APIRouterV11(options)
|
||||||
|
|
||||||
def request(self, method, action, body, headers):
|
def request(self, method, action, body, headers):
|
||||||
# TODO: remove version prefix from action!
|
# TODO: remove version prefix from action!
|
||||||
|
23
quantum/tests/unit/declare_flags.py
Normal file
23
quantum/tests/unit/declare_flags.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# Copyright 2010 United States Government as represented by the
|
||||||
|
# Administrator of the National Aeronautics and Space Administration.
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
from quantum.common import flags
|
||||||
|
|
||||||
|
FLAGS = flags.FLAGS
|
||||||
|
|
||||||
|
flags.DEFINE_integer('answer', 42, 'test flag')
|
23
quantum/tests/unit/runtime_flags.py
Normal file
23
quantum/tests/unit/runtime_flags.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# Copyright 2010 United States Government as represented by the
|
||||||
|
# Administrator of the National Aeronautics and Space Administration.
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
from quantum.common import flags
|
||||||
|
|
||||||
|
FLAGS = flags.FLAGS
|
||||||
|
|
||||||
|
flags.DEFINE_integer('runtime_answer', 54, 'test flag')
|
File diff suppressed because it is too large
Load Diff
@ -43,7 +43,8 @@ class CLITest(unittest.TestCase):
|
|||||||
options = {}
|
options = {}
|
||||||
options['plugin_provider'] = \
|
options['plugin_provider'] = \
|
||||||
'quantum.plugins.sample.SamplePlugin.FakePlugin'
|
'quantum.plugins.sample.SamplePlugin.FakePlugin'
|
||||||
self.api = server.APIRouterV1(options)
|
#TODO: make the version of the API router configurable
|
||||||
|
self.api = server.APIRouterV11(options)
|
||||||
|
|
||||||
self.tenant_id = "test_tenant"
|
self.tenant_id = "test_tenant"
|
||||||
self.network_name_1 = "test_network_1"
|
self.network_name_1 = "test_network_1"
|
||||||
|
197
quantum/tests/unit/test_flags.py
Normal file
197
quantum/tests/unit/test_flags.py
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# Copyright 2010 United States Government as represented by the
|
||||||
|
# Administrator of the National Aeronautics and Space Administration.
|
||||||
|
# All Rights Reserved.
|
||||||
|
# Copyright 2011 Red Hat, Inc.
|
||||||
|
#
|
||||||
|
# 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 gflags
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from quantum.common import flags
|
||||||
|
|
||||||
|
FLAGS = flags.FLAGS
|
||||||
|
flags.DEFINE_string('flags_unittest', 'foo', 'for testing purposes only')
|
||||||
|
|
||||||
|
|
||||||
|
class FlagsTestCase(unittest.TestCase):
|
||||||
|
|
||||||
|
def flags(self, **kw):
|
||||||
|
"""Override flag variables for a test."""
|
||||||
|
for k, v in kw.iteritems():
|
||||||
|
setattr(FLAGS, k, v)
|
||||||
|
|
||||||
|
def reset_flags(self):
|
||||||
|
"""Resets all flag variables for the test.
|
||||||
|
|
||||||
|
Runs after each test.
|
||||||
|
|
||||||
|
"""
|
||||||
|
FLAGS.Reset()
|
||||||
|
for k, v in self._original_flags.iteritems():
|
||||||
|
setattr(FLAGS, k, v)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(FlagsTestCase, self).setUp()
|
||||||
|
self.FLAGS = flags.FlagValues()
|
||||||
|
self.global_FLAGS = flags.FLAGS
|
||||||
|
self._original_flags = FLAGS.FlagValuesDict()
|
||||||
|
|
||||||
|
def test_define(self):
|
||||||
|
self.assert_('string' not in self.FLAGS)
|
||||||
|
self.assert_('int' not in self.FLAGS)
|
||||||
|
self.assert_('false' not in self.FLAGS)
|
||||||
|
self.assert_('true' not in self.FLAGS)
|
||||||
|
|
||||||
|
flags.DEFINE_string('string', 'default', 'desc',
|
||||||
|
flag_values=self.FLAGS)
|
||||||
|
flags.DEFINE_integer('int', 1, 'desc', flag_values=self.FLAGS)
|
||||||
|
flags.DEFINE_bool('false', False, 'desc', flag_values=self.FLAGS)
|
||||||
|
flags.DEFINE_bool('true', True, 'desc', flag_values=self.FLAGS)
|
||||||
|
|
||||||
|
self.assert_(self.FLAGS['string'])
|
||||||
|
self.assert_(self.FLAGS['int'])
|
||||||
|
self.assert_(self.FLAGS['false'])
|
||||||
|
self.assert_(self.FLAGS['true'])
|
||||||
|
self.assertEqual(self.FLAGS.string, 'default')
|
||||||
|
self.assertEqual(self.FLAGS.int, 1)
|
||||||
|
self.assertEqual(self.FLAGS.false, False)
|
||||||
|
self.assertEqual(self.FLAGS.true, True)
|
||||||
|
|
||||||
|
argv = ['flags_test',
|
||||||
|
'--string', 'foo',
|
||||||
|
'--int', '2',
|
||||||
|
'--false',
|
||||||
|
'--notrue']
|
||||||
|
|
||||||
|
self.FLAGS(argv)
|
||||||
|
self.assertEqual(self.FLAGS.string, 'foo')
|
||||||
|
self.assertEqual(self.FLAGS.int, 2)
|
||||||
|
self.assertEqual(self.FLAGS.false, True)
|
||||||
|
self.assertEqual(self.FLAGS.true, False)
|
||||||
|
|
||||||
|
def test_define_float(self):
|
||||||
|
flags.DEFINE_float('float', 6.66, 'desc', flag_values=self.FLAGS)
|
||||||
|
self.assertEqual(self.FLAGS.float, 6.66)
|
||||||
|
|
||||||
|
def test_define_multistring(self):
|
||||||
|
flags.DEFINE_multistring('multi', [], 'desc', flag_values=self.FLAGS)
|
||||||
|
|
||||||
|
argv = ['flags_test', '--multi', 'foo', '--multi', 'bar']
|
||||||
|
self.FLAGS(argv)
|
||||||
|
|
||||||
|
self.assertEqual(self.FLAGS.multi, ['foo', 'bar'])
|
||||||
|
|
||||||
|
def test_define_list(self):
|
||||||
|
flags.DEFINE_list('list', ['foo'], 'desc', flag_values=self.FLAGS)
|
||||||
|
|
||||||
|
self.assert_(self.FLAGS['list'])
|
||||||
|
self.assertEqual(self.FLAGS.list, ['foo'])
|
||||||
|
|
||||||
|
argv = ['flags_test', '--list=a,b,c,d']
|
||||||
|
self.FLAGS(argv)
|
||||||
|
|
||||||
|
self.assertEqual(self.FLAGS.list, ['a', 'b', 'c', 'd'])
|
||||||
|
|
||||||
|
def test_error(self):
|
||||||
|
flags.DEFINE_integer('error', 1, 'desc', flag_values=self.FLAGS)
|
||||||
|
|
||||||
|
self.assertEqual(self.FLAGS.error, 1)
|
||||||
|
|
||||||
|
argv = ['flags_test', '--error=foo']
|
||||||
|
self.assertRaises(gflags.IllegalFlagValue, self.FLAGS, argv)
|
||||||
|
|
||||||
|
def test_declare(self):
|
||||||
|
self.assert_('answer' not in self.global_FLAGS)
|
||||||
|
flags.DECLARE('answer', 'quantum.tests.unit.declare_flags')
|
||||||
|
self.assert_('answer' in self.global_FLAGS)
|
||||||
|
self.assertEqual(self.global_FLAGS.answer, 42)
|
||||||
|
|
||||||
|
# Make sure we don't overwrite anything
|
||||||
|
self.global_FLAGS.answer = 256
|
||||||
|
self.assertEqual(self.global_FLAGS.answer, 256)
|
||||||
|
flags.DECLARE('answer', 'quantum.tests.unit.declare_flags')
|
||||||
|
self.assertEqual(self.global_FLAGS.answer, 256)
|
||||||
|
|
||||||
|
def test_getopt_non_interspersed_args(self):
|
||||||
|
self.assert_('runtime_answer' not in self.global_FLAGS)
|
||||||
|
|
||||||
|
argv = ['flags_test', 'extra_arg', '--runtime_answer=60']
|
||||||
|
args = self.global_FLAGS(argv)
|
||||||
|
self.assertEqual(len(args), 3)
|
||||||
|
self.assertEqual(argv, args)
|
||||||
|
|
||||||
|
def test_runtime_and_unknown_flags(self):
|
||||||
|
self.assert_('runtime_answer' not in self.global_FLAGS)
|
||||||
|
|
||||||
|
argv = ['flags_test', '--runtime_answer=60', 'extra_arg']
|
||||||
|
args = self.global_FLAGS(argv)
|
||||||
|
self.assertEqual(len(args), 2)
|
||||||
|
self.assertEqual(args[1], 'extra_arg')
|
||||||
|
|
||||||
|
self.assert_('runtime_answer' not in self.global_FLAGS)
|
||||||
|
|
||||||
|
import quantum.tests.unit.runtime_flags
|
||||||
|
|
||||||
|
self.assert_('runtime_answer' in self.global_FLAGS)
|
||||||
|
self.assertEqual(self.global_FLAGS.runtime_answer, 60)
|
||||||
|
|
||||||
|
def test_flag_overrides(self):
|
||||||
|
self.assertEqual(FLAGS.flags_unittest, 'foo')
|
||||||
|
self.flags(flags_unittest='bar')
|
||||||
|
self.assertEqual(FLAGS.flags_unittest, 'bar')
|
||||||
|
self.assertEqual(FLAGS['flags_unittest'].value, 'bar')
|
||||||
|
self.assertEqual(FLAGS.FlagValuesDict()['flags_unittest'], 'bar')
|
||||||
|
self.reset_flags()
|
||||||
|
self.assertEqual(FLAGS.flags_unittest, 'foo')
|
||||||
|
self.assertEqual(FLAGS['flags_unittest'].value, 'foo')
|
||||||
|
self.assertEqual(FLAGS.FlagValuesDict()['flags_unittest'], 'foo')
|
||||||
|
|
||||||
|
def test_flagfile(self):
|
||||||
|
flags.DEFINE_string('string', 'default', 'desc',
|
||||||
|
flag_values=self.FLAGS)
|
||||||
|
flags.DEFINE_integer('int', 1, 'desc', flag_values=self.FLAGS)
|
||||||
|
flags.DEFINE_bool('false', False, 'desc', flag_values=self.FLAGS)
|
||||||
|
flags.DEFINE_bool('true', True, 'desc', flag_values=self.FLAGS)
|
||||||
|
|
||||||
|
(fd, path) = tempfile.mkstemp(prefix='nova', suffix='.flags')
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.write(fd, '--string=foo\n--int=2\n--false\n--notrue\n')
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
self.FLAGS(['flags_test', '--flagfile=' + path])
|
||||||
|
|
||||||
|
self.assertEqual(self.FLAGS.string, 'foo')
|
||||||
|
self.assertEqual(self.FLAGS.int, 2)
|
||||||
|
self.assertEqual(self.FLAGS.false, True)
|
||||||
|
self.assertEqual(self.FLAGS.true, False)
|
||||||
|
finally:
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
def test_defaults(self):
|
||||||
|
flags.DEFINE_string('foo', 'bar', 'help', flag_values=self.FLAGS)
|
||||||
|
self.assertEqual(self.FLAGS.foo, 'bar')
|
||||||
|
|
||||||
|
self.FLAGS['foo'].SetDefault('blaa')
|
||||||
|
self.assertEqual(self.FLAGS.foo, 'blaa')
|
||||||
|
|
||||||
|
def test_templated_values(self):
|
||||||
|
flags.DEFINE_string('foo', 'foo', 'help', flag_values=self.FLAGS)
|
||||||
|
flags.DEFINE_string('bar', 'bar', 'help', flag_values=self.FLAGS)
|
||||||
|
flags.DEFINE_string('blaa', '$foo$bar', 'help', flag_values=self.FLAGS)
|
||||||
|
self.assertEqual(self.FLAGS.blaa, 'foobar')
|
518
quantum/wsgi.py
518
quantum/wsgi.py
@ -1,7 +1,6 @@
|
|||||||
|
|
||||||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
#
|
|
||||||
# Copyright 2011, Nicira Networks, Inc.
|
# Copyright 2011 OpenStack LLC.
|
||||||
# All Rights Reserved.
|
# All Rights Reserved.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
@ -27,13 +26,14 @@ eventlet.patcher.monkey_patch(all=False, socket=True)
|
|||||||
import routes.middleware
|
import routes.middleware
|
||||||
import webob.dec
|
import webob.dec
|
||||||
import webob.exc
|
import webob.exc
|
||||||
from xml.dom import minidom
|
|
||||||
|
|
||||||
|
from lxml import etree
|
||||||
|
from xml.dom import minidom
|
||||||
|
from xml.parsers import expat
|
||||||
|
|
||||||
from quantum.common import exceptions as exception
|
from quantum.common import exceptions as exception
|
||||||
from quantum.common import utils
|
from quantum.common import utils
|
||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger('quantum.common.wsgi')
|
LOG = logging.getLogger('quantum.common.wsgi')
|
||||||
|
|
||||||
|
|
||||||
@ -57,8 +57,9 @@ def run_server(application, port):
|
|||||||
class Server(object):
|
class Server(object):
|
||||||
"""Server class to manage multiple WSGI sockets and applications."""
|
"""Server class to manage multiple WSGI sockets and applications."""
|
||||||
|
|
||||||
def __init__(self, threads=1000):
|
def __init__(self, name, threads=1000):
|
||||||
self.pool = eventlet.GreenPool(threads)
|
self.pool = eventlet.GreenPool(threads)
|
||||||
|
self.name = name
|
||||||
|
|
||||||
def start(self, application, port, host='0.0.0.0', backlog=128):
|
def start(self, application, port, host='0.0.0.0', backlog=128):
|
||||||
"""Run a WSGI server with the given application."""
|
"""Run a WSGI server with the given application."""
|
||||||
@ -119,12 +120,13 @@ class Request(webob.Request):
|
|||||||
def best_match_content_type(self):
|
def best_match_content_type(self):
|
||||||
"""Determine the most acceptable content-type.
|
"""Determine the most acceptable content-type.
|
||||||
|
|
||||||
Based on the query extension then the Accept header.
|
Based on:
|
||||||
|
1) URI extension (.json/.xml)
|
||||||
|
2) Content-type header
|
||||||
|
3) Accept* headers
|
||||||
"""
|
"""
|
||||||
# First lookup http request
|
# First lookup http request
|
||||||
parts = self.path.rsplit('.', 1)
|
parts = self.path.rsplit('.', 1)
|
||||||
LOG.debug("Request parts:%s", parts)
|
|
||||||
if len(parts) > 1:
|
if len(parts) > 1:
|
||||||
format = parts[1]
|
format = parts[1]
|
||||||
if format in ['json', 'xml']:
|
if format in ['json', 'xml']:
|
||||||
@ -148,10 +150,379 @@ class Request(webob.Request):
|
|||||||
type = self.content_type
|
type = self.content_type
|
||||||
if type in allowed_types:
|
if type in allowed_types:
|
||||||
return type
|
return type
|
||||||
LOG.debug(_("Wrong Content-Type: %s") % type)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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 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 utils.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 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
|
||||||
|
|
||||||
|
def _to_xml(self, root):
|
||||||
|
"""Convert the xml object to an xml string."""
|
||||||
|
return etree.tostring(root, encoding='UTF-8', xml_declaration=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseHeaderSerializer(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 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 utils.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 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):
|
||||||
|
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):
|
||||||
|
try:
|
||||||
|
content_type = request.best_match_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 {}
|
||||||
|
|
||||||
|
if not len(request.body) > 0:
|
||||||
|
LOG.debug(_("Empty body 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()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
class Application(object):
|
class Application(object):
|
||||||
"""Base WSGI application wrapper. Subclasses need to implement __call__."""
|
"""Base WSGI application wrapper. Subclasses need to implement __call__."""
|
||||||
|
|
||||||
@ -317,6 +688,131 @@ class Router(object):
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
class Resource(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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, controller, deserializer=None, serializer=None):
|
||||||
|
"""
|
||||||
|
:param controller: object that implement methods created by routes lib
|
||||||
|
:param deserializer: object that can serialize the output of a
|
||||||
|
controller into a webob response
|
||||||
|
:param serializer: object that can deserialize a webob request
|
||||||
|
into necessary pieces
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.controller = controller
|
||||||
|
self.deserializer = deserializer or RequestDeserializer()
|
||||||
|
self.serializer = serializer or ResponseSerializer()
|
||||||
|
# use serializer's xmlns for populating Fault generator xmlns
|
||||||
|
xml_serializer = self.serializer.body_serializers['application/xml']
|
||||||
|
if hasattr(xml_serializer, 'xmlns'):
|
||||||
|
self._xmlns = xml_serializer.xmlns
|
||||||
|
|
||||||
|
@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})
|
||||||
|
|
||||||
|
try:
|
||||||
|
action, args, accept = self.deserializer.deserialize(request)
|
||||||
|
except exception.InvalidContentType:
|
||||||
|
msg = _("Unsupported Content-Type")
|
||||||
|
LOG.exception("InvalidContentType:%s", msg)
|
||||||
|
return Fault(webob.exc.HTTPBadRequest(explanation=msg),
|
||||||
|
self._xmlns)
|
||||||
|
except exception.MalformedRequestBody:
|
||||||
|
msg = _("Malformed request body")
|
||||||
|
LOG.exception("MalformedRequestBody:%s", msg)
|
||||||
|
return Fault(webob.exc.HTTPBadRequest(explanation=msg),
|
||||||
|
self._xmlns)
|
||||||
|
|
||||||
|
try:
|
||||||
|
action_result = self.dispatch(request, action, args)
|
||||||
|
except webob.exc.HTTPException as ex:
|
||||||
|
LOG.info(_("HTTP exception thrown: %s"), unicode(ex))
|
||||||
|
action_result = Fault(ex, self._xmlns)
|
||||||
|
|
||||||
|
if type(action_result) is dict or action_result is None:
|
||||||
|
response = self.serializer.serialize(action_result,
|
||||||
|
accept,
|
||||||
|
action=action)
|
||||||
|
else:
|
||||||
|
response = action_result
|
||||||
|
|
||||||
|
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 dispatch(self, request, action, action_args):
|
||||||
|
"""Find action-spefic method on controller and call it."""
|
||||||
|
|
||||||
|
controller_method = getattr(self.controller, action)
|
||||||
|
try:
|
||||||
|
#NOTE(salvatore-orlando): the controller method must have
|
||||||
|
# an argument whose name is 'request'
|
||||||
|
return controller_method(request=request, **action_args)
|
||||||
|
except TypeError as exc:
|
||||||
|
LOG.exception(exc)
|
||||||
|
return Fault(webob.exc.HTTPBadRequest(),
|
||||||
|
self._xmlns)
|
||||||
|
|
||||||
|
|
||||||
|
class Fault(webob.exc.HTTPException):
|
||||||
|
""" Generates an HTTP response from a webob HTTP exception"""
|
||||||
|
|
||||||
|
def __init__(self, exception, xmlns=None):
|
||||||
|
"""Creates a Fault for the given webob.exc.exception."""
|
||||||
|
self.wrapped_exc = exception
|
||||||
|
self.status_int = self.wrapped_exc.status_int
|
||||||
|
self._xmlns = xmlns
|
||||||
|
|
||||||
|
@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 = hasattr(self.wrapped_exc, 'title') and \
|
||||||
|
self.wrapped_exc.title or "quantumServiceFault"
|
||||||
|
fault_data = {
|
||||||
|
fault_name: {
|
||||||
|
'code': code,
|
||||||
|
'message': self.wrapped_exc.explanation,
|
||||||
|
'detail': str(self.wrapped_exc.detail)}}
|
||||||
|
# 'code' is an attribute on the fault tag itself
|
||||||
|
metadata = {'application/xml': {'attributes': {fault_name: 'code'}}}
|
||||||
|
xml_serializer = XMLDictSerializer(metadata, self._xmlns)
|
||||||
|
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
|
||||||
|
return self.wrapped_exc
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE(salvatore-orlando): this class will go once the
|
||||||
|
# extension API framework is updated
|
||||||
class Controller(object):
|
class Controller(object):
|
||||||
"""WSGI app that dispatched to methods.
|
"""WSGI app that dispatched to methods.
|
||||||
|
|
||||||
@ -389,6 +885,8 @@ class Controller(object):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE(salvatore-orlando): this class will go once the
|
||||||
|
# extension API framework is updated
|
||||||
class Serializer(object):
|
class Serializer(object):
|
||||||
"""Serializes and deserializes dictionaries to certain MIME types."""
|
"""Serializes and deserializes dictionaries to certain MIME types."""
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user