diff options
author | Dolph Mathews <dolph.mathews@gmail.com> | 2012-02-10 14:52:13 -0600 |
---|---|---|
committer | Dolph Mathews <dolph.mathews@gmail.com> | 2012-02-27 09:58:04 -0600 |
commit | 212489084fac8de20718bfccad2f77cbfa7ea3e2 (patch) | |
tree | 4cfada718772bb13e93be1f6c8f3b932064eb7ab | |
parent | e23ecc6893db337671f75b6cc069d96a183940e8 (diff) | |
download | keystone-212489084fac8de20718bfccad2f77cbfa7ea3e2.tar.gz keystone-212489084fac8de20718bfccad2f77cbfa7ea3e2.tar.xz keystone-212489084fac8de20718bfccad2f77cbfa7ea3e2.zip |
XML de/serialization (bug 928058)
Middleware rewrites incoming XML requests as JSON, and outgoing JSON as
XML, per Accept and Content-Type headers.
Tests assert that core API methods support WADL/XSD specs, and cover
JSON content as well.
Change-Id: I6897971dd745766cbc472fd6e5346b1b34d933b0
-rw-r--r-- | etc/keystone.conf | 11 | ||||
-rw-r--r-- | keystone/common/serializer.py | 197 | ||||
-rw-r--r-- | keystone/middleware/core.py | 31 | ||||
-rw-r--r-- | keystone/service.py | 4 | ||||
-rw-r--r-- | tests/test_content_types.py | 578 | ||||
-rw-r--r-- | tests/test_middleware.py | 58 | ||||
-rw-r--r-- | tests/test_serializer.py | 155 | ||||
-rw-r--r-- | tests/test_versions.py | 126 | ||||
-rw-r--r-- | tools/pip-requires | 1 | ||||
-rw-r--r-- | tools/pip-requires-test | 1 |
10 files changed, 1105 insertions, 57 deletions
diff --git a/etc/keystone.conf b/etc/keystone.conf index d33a0e47..3a4e6a32 100644 --- a/etc/keystone.conf +++ b/etc/keystone.conf @@ -50,6 +50,9 @@ paste.filter_factory = keystone.middleware:TokenAuthMiddleware.factory [filter:admin_token_auth] paste.filter_factory = keystone.middleware:AdminTokenAuthMiddleware.factory +[filter:xml_body] +paste.filter_factory = keystone.middleware:XmlBodyMiddleware.factory + [filter:json_body] paste.filter_factory = keystone.middleware:JsonBodyMiddleware.factory @@ -66,10 +69,10 @@ paste.app_factory = keystone.service:public_app_factory paste.app_factory = keystone.service:admin_app_factory [pipeline:public_api] -pipeline = token_auth admin_token_auth json_body debug ec2_extension public_service +pipeline = token_auth admin_token_auth xml_body json_body debug ec2_extension public_service [pipeline:admin_api] -pipeline = token_auth admin_token_auth json_body debug ec2_extension crud_extension admin_service +pipeline = token_auth admin_token_auth xml_body json_body debug ec2_extension crud_extension admin_service [app:public_version_service] paste.app_factory = keystone.service:public_version_app_factory @@ -78,10 +81,10 @@ paste.app_factory = keystone.service:public_version_app_factory paste.app_factory = keystone.service:admin_version_app_factory [pipeline:public_version_api] -pipeline = public_version_service +pipeline = xml_body public_version_service [pipeline:admin_version_api] -pipeline = admin_version_service +pipeline = xml_body admin_version_service [composite:main] use = egg:Paste#urlmap diff --git a/keystone/common/serializer.py b/keystone/common/serializer.py new file mode 100644 index 00000000..c5e9b770 --- /dev/null +++ b/keystone/common/serializer.py @@ -0,0 +1,197 @@ +""" +Dict <--> XML de/serializer. + +The identity API prefers attributes over elements, so we serialize that way +by convention, with a few hardcoded exceptions. + +""" + +from lxml import etree +import re + + +DOCTYPE = '<?xml version="1.0" encoding="UTF-8"?>' +XMLNS = 'http://docs.openstack.org/identity/api/v2.0' + + +def from_xml(xml): + """Deserialize XML to a dictionary.""" + if xml is None: + return None + + deserializer = XmlDeserializer() + return deserializer(xml) + + +def to_xml(d, xmlns=None): + """Serialize a dictionary to XML.""" + if d is None: + return None + + serialize = XmlSerializer() + return serialize(d, xmlns) + + +class XmlDeserializer(object): + def __call__(self, xml_str): + """Returns a dictionary populated by decoding the given xml string.""" + dom = etree.fromstring(xml_str.strip()) + return self.walk_element(dom) + + @staticmethod + def _tag_name(tag): + """Remove the namespace from the tagname. + + TODO(dolph): We might care about the namespace at some point. + + >>> XmlDeserializer._tag_name('{xmlNamespace}tagName') + 'tagName' + + """ + m = re.search('[^}]+$', tag) + return m.string[m.start():] + + def walk_element(self, element): + """Populates a dictionary by walking an etree element.""" + values = {} + for k, v in element.attrib.iteritems(): + # boolean-looking attributes become booleans in JSON + if k in ['enabled']: + if v in ['true']: + v = True + elif v in ['false']: + v = False + + values[k] = v + + text = None + if element.text is not None: + text = element.text.strip() + + # current spec does not have attributes on an element with text + values = values or text or {} + + for child in [self.walk_element(x) for x in element]: + values = dict(values.items() + child.items()) + + return {XmlDeserializer._tag_name(element.tag): values} + + +class XmlSerializer(object): + def __call__(self, d, xmlns=None): + """Returns an xml etree populated by the given dictionary. + + Optionally, namespace the etree by specifying an ``xmlns``. + + """ + # FIXME(dolph): skipping links for now + for key in d.keys(): + if '_links' in key: + d.pop(key) + + assert len(d.keys()) == 1, ('Cannot encode more than one root ' + 'element: %s' % d.keys()) + + # name the root dom element + name = d.keys()[0] + + # only the root dom element gets an xlmns + root = etree.Element(name, xmlns=(xmlns or XMLNS)) + + self.populate_element(root, d[name]) + + # TODO(dolph): you can get a doctype from lxml, using ElementTrees + return '%s\n%s' % (DOCTYPE, etree.tostring(root, pretty_print=True)) + + def _populate_list(self, element, k, v): + """Populates an element with a key & list value.""" + # spec has a lot of inconsistency here! + container = element + + if k == 'media-types': + # xsd compliance: <media-types> contains <media-type>s + # find an existing <media-types> element or make one + container = element.find('media-types') + if container is None: + container = etree.Element(k) + element.append(container) + name = k[:-1] + elif k == 'serviceCatalog': + # xsd compliance: <serviceCatalog> contains <service>s + container = etree.Element(k) + element.append(container) + name = 'service' + elif k == 'values' and element.tag[-1] == 's': + # OS convention is to contain lists in a 'values' element, + # so the list itself can have attributes, which is + # unnecessary in XML + name = element.tag[:-1] + elif k[-1] == 's': + name = k[:-1] + else: + name = k + + for item in v: + child = etree.Element(name) + self.populate_element(child, item) + container.append(child) + + def _populate_dict(self, element, k, v): + """Populates an element with a key & dictionary value.""" + child = etree.Element(k) + self.populate_element(child, v) + element.append(child) + + def _populate_bool(self, element, k, v): + """Populates an element with a key & boolean value.""" + # booleans are 'true' and 'false' + element.set(k, unicode(v).lower()) + + def _populate_str(self, element, k, v): + """Populates an element with a key & string value.""" + if k in ['description']: + # always becomes an element + child = etree.Element(k) + child.text = unicode(v) + element.append(child) + else: + # add attributes to the current element + element.set(k, unicode(v)) + + def _populate_number(self, element, k, v): + """Populates an element with a key & numeric value.""" + # numbers can be handled as strings + self._populate_str(element, k, v) + + def populate_element(self, element, value): + """Populates an etree with the given value.""" + if isinstance(value, list): + self._populate_sequence(element, value) + elif isinstance(value, dict): + self._populate_tree(element, value) + + def _populate_sequence(self, element, l): + """Populates an etree with a sequence of elements, given a list.""" + # xsd compliance: child elements are singular: <users> has <user>s + name = element.tag + if element.tag[-1] == 's': + name = element.tag[:-1] + + for item in l: + child = etree.Element(name) + self.populate_element(child, item) + element.append(child) + + def _populate_tree(self, element, d): + """Populates an etree with attributes & elements, given a dict.""" + for k, v in d.iteritems(): + if isinstance(v, dict): + self._populate_dict(element, k, v) + elif isinstance(v, list): + self._populate_list(element, k, v) + elif isinstance(v, bool): + self._populate_bool(element, k, v) + elif isinstance(v, basestring): + self._populate_str(element, k, v) + elif type(v) in [int, float, long, complex]: + self._populate_number(element, k, v) diff --git a/keystone/middleware/core.py b/keystone/middleware/core.py index f5ef7794..19212e0c 100644 --- a/keystone/middleware/core.py +++ b/keystone/middleware/core.py @@ -19,6 +19,8 @@ import json import webob.exc from keystone import config +from keystone import exception +from keystone.common import serializer from keystone.common import wsgi @@ -109,7 +111,7 @@ class JsonBodyMiddleware(wsgi.Middleware): try: params_parsed = json.loads(params_json) except ValueError: - msg = "Malformed json in request body" + msg = 'Malformed json in request body' raise webob.exc.HTTPBadRequest(explanation=msg) finally: if not params_parsed: @@ -124,3 +126,30 @@ class JsonBodyMiddleware(wsgi.Middleware): params[k] = v request.environ[PARAMS_ENV] = params + + +class XmlBodyMiddleware(wsgi.Middleware): + """De/serializes XML to/from JSON.""" + @webob.dec.wsgify(RequestClass=wsgi.Request) + def __call__(self, request): + self.process_request(request) + response = request.get_response(self.application) + self.process_response(request, response) + return response + + def process_request(self, request): + """Transform the request from XML to JSON.""" + incoming_xml = 'application/xml' in str(request.content_type) + if incoming_xml and request.body: + request.content_type = 'application/json' + request.body = json.dumps(serializer.from_xml(request.body)) + + def process_response(self, request, response): + """Transform the response from JSON to XML.""" + outgoing_xml = 'application/xml' in str(request.accept) + if outgoing_xml and response.body: + response.content_type = 'application/xml' + try: + response.body = serializer.to_xml(json.loads(response.body)) + except: + raise exception.Error(message=response.body) diff --git a/keystone/service.py b/keystone/service.py index 25ced533..b0bfd10c 100644 --- a/keystone/service.py +++ b/keystone/service.py @@ -163,6 +163,10 @@ class VersionController(wsgi.Application): "base": "application/json", "type": "application/vnd.openstack.identity-v2.0" "+json" + }, { + "base": "application/xml", + "type": "application/vnd.openstack.identity-v2.0" + "+xml" }] }] } diff --git a/tests/test_content_types.py b/tests/test_content_types.py new file mode 100644 index 00000000..016ffa9a --- /dev/null +++ b/tests/test_content_types.py @@ -0,0 +1,578 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +import httplib +import json + +from lxml import etree +import nose.exc + +from keystone import test +from keystone.common import serializer + +import default_fixtures + + +class RestfulTestCase(test.TestCase): + """Performs restful tests against the WSGI app over HTTP. + + This class launches public & admin WSGI servers for every test, which can + be accessed by calling ``public_request()`` or ``admin_request()``, + respectfully. + + ``restful_request()`` and ``request()`` methods are also exposed if you + need to bypass restful conventions or access HTTP details in your test + implementation. + + Two new asserts are provided: + + * ``assertResponseSuccessful``: called automatically for every request + unless an ``expected_status`` is provided + * ``assertResponseStatus``: called instead of ``assertResponseSuccessful``, + if an ``expected_status`` is provided + + Requests are automatically serialized according to the defined + ``content_type``. Responses are automatically deserialized as well, and + available in the ``response.body`` attribute. The original body content is + available in the ``response.raw`` attribute. + + """ + + # default content type to test + content_type = 'json' + + def setUp(self): + super(RestfulTestCase, self).setUp() + + self.load_backends() + self.load_fixtures(default_fixtures) + + self.public_server = self.serveapp('keystone', name='main') + self.admin_server = self.serveapp('keystone', name='admin') + + # TODO(termie): is_admin is being deprecated once the policy stuff + # is all working + # TODO(termie): add an admin user to the fixtures and use that user + # override the fixtures, for now + self.metadata_foobar = self.identity_api.update_metadata( + self.user_foo['id'], + self.tenant_bar['id'], + dict(roles=['keystone_admin'], is_admin='1')) + + def tearDown(self): + """Kill running servers and release references to avoid leaks.""" + self.public_server.kill() + self.admin_server.kill() + self.public_server = None + self.admin_server = None + super(RestfulTestCase, self).tearDown() + + def request(self, host='0.0.0.0', port=80, method='GET', path='/', + headers=None, body=None, expected_status=None): + """Perform request and fetch httplib.HTTPResponse from the server.""" + + # Initialize headers dictionary + headers = {} if not headers else headers + + connection = httplib.HTTPConnection(host, port, timeout=10) + + # Perform the request + connection.request(method, path, body, headers) + + # Retrieve the response so we can close the connection + response = connection.getresponse() + + response.body = response.read() + + # Close the connection + connection.close() + + # Automatically assert HTTP status code + if expected_status: + self.assertResponseStatus(response, expected_status) + else: + self.assertResponseSuccessful(response) + + # Contains the response headers, body, etc + return response + + def assertResponseSuccessful(self, response): + """Asserts that a status code lies inside the 2xx range. + + :param response: :py:class:`httplib.HTTPResponse` to be + verified to have a status code between 200 and 299. + + example:: + + >>> self.assertResponseSuccessful(response, 203) + """ + self.assertTrue(response.status >= 200 and response.status <= 299, + 'Status code %d is outside of the expected range (2xx)\n\n%s' % + (response.status, response.body)) + + def assertResponseStatus(self, response, expected_status): + """Asserts a specific status code on the response. + + :param response: :py:class:`httplib.HTTPResponse` + :param assert_status: The specific ``status`` result expected + + example:: + + >>> self.assertResponseStatus(response, 203) + """ + self.assertEqual(response.status, expected_status, + 'Status code %s is not %s, as expected)\n\n%s' % + (response.status, expected_status, response.body)) + + def _to_content_type(self, body, headers, content_type=None): + """Attempt to encode JSON and XML automatically.""" + content_type = content_type or self.content_type + + if content_type == 'json': + headers['Accept'] = 'application/json' + if body: + headers['Content-Type'] = 'application/json' + return json.dumps(body) + elif content_type == 'xml': + headers['Accept'] = 'application/xml' + if body: + headers['Content-Type'] = 'application/xml' + return serializer.to_xml(body) + + def _from_content_type(self, response, content_type=None): + """Attempt to decode JSON and XML automatically, if detected.""" + content_type = content_type or self.content_type + + # make the original response body available, for convenience + response.raw = response.body + + if response.body is not None and response.body.strip(): + # if a body is provided, a Content-Type is also expected + header = response.getheader('Content-Type', None) + self.assertIn(self.content_type, header) + + if self.content_type == 'json': + response.body = json.loads(response.body) + elif self.content_type == 'xml': + response.body = etree.fromstring(response.body) + + def restful_request(self, headers=None, body=None, token=None, **kwargs): + """Serializes/deserializes json/xml as request/response body. + + .. WARNING:: + + * Existing Accept header will be overwritten. + * Existing Content-Type header will be overwritten. + + """ + # Initialize headers dictionary + headers = {} if not headers else headers + + if token is not None: + headers['X-Auth-Token'] = token + + body = self._to_content_type(body, headers) + + # Perform the HTTP request/response + response = self.request(headers=headers, body=body, **kwargs) + + self._from_content_type(response) + + # we can save some code & improve coverage by always doing this + if response.status >= 400: + self.assertValidErrorResponse(response) + + # Contains the decoded response.body + return response + + def _get_port(self, server): + return server.socket_info['socket'][1] + + def _public_port(self): + return self._get_port(self.public_server) + + def _admin_port(self): + return self._get_port(self.admin_server) + + def public_request(self, port=None, **kwargs): + kwargs['port'] = port or self._public_port() + return self.restful_request(**kwargs) + + def admin_request(self, port=None, **kwargs): + kwargs['port'] = port or self._admin_port() + return self.restful_request(**kwargs) + + def get_scoped_token(self): + """Convenience method so that we can test authenticated requests.""" + r = self.public_request(method='POST', path='/v2.0/tokens', body={ + 'auth': { + 'passwordCredentials': { + 'username': self.user_foo['name'], + 'password': self.user_foo['password'], + }, + 'tenantId': self.tenant_bar['id'], + }, + }) + return self._get_token_id(r) + + def _get_token_id(self, r): + """Helper method to return a token ID from a response. + + This needs to be overridden by child classes for on their content type. + + """ + raise NotImplementedError() + + +class CoreApiTests(object): + def assertValidError(self, error): + """Applicable to XML and JSON.""" + try: + print error.attrib + except: + pass + self.assertIsNotNone(error.get('code')) + self.assertIsNotNone(error.get('title')) + self.assertIsNotNone(error.get('message')) + + def assertValidTenant(self, tenant): + """Applicable to XML and JSON.""" + self.assertIsNotNone(tenant.get('id')) + self.assertIsNotNone(tenant.get('name')) + + def assertValidUser(self, user): + """Applicable to XML and JSON.""" + self.assertIsNotNone(user.get('id')) + self.assertIsNotNone(user.get('name')) + + def assertValidRole(self, tenant): + """Applicable to XML and JSON.""" + self.assertIsNotNone(tenant.get('id')) + self.assertIsNotNone(tenant.get('name')) + + def test_public_multiple_choice(self): + r = self.public_request(path='/', expected_status=300) + self.assertValidMultipleChoiceResponse(r) + + def test_admin_multiple_choice(self): + r = self.admin_request(path='/', expected_status=300) + self.assertValidMultipleChoiceResponse(r) + + def test_public_version(self): + raise nose.exc.SkipTest('Blocked by bug 925548') + + r = self.public_request(path='/v2.0/') + self.assertValidVersionResponse(r) + + def test_admin_version(self): + raise nose.exc.SkipTest('Blocked by bug 925548') + + r = self.admin_request(path='/v2.0/') + self.assertValidVersionResponse(r) + + def test_public_extensions(self): + raise nose.exc.SkipTest('Blocked by bug 928054') + + self.public_request(path='/v2.0/extensions',) + + def test_admin_extensions(self): + raise nose.exc.SkipTest('Blocked by bug 928054') + + self.admin_request(path='/v2.0/extensions',) + + def test_authenticate(self): + r = self.public_request(method='POST', path='/v2.0/tokens', body={ + 'auth': { + 'passwordCredentials': { + 'username': self.user_foo['name'], + 'password': self.user_foo['password'], + }, + 'tenantId': self.tenant_bar['id'], + }, + }, + # TODO(dolph): creating a token should result in a 201 Created + expected_status=200) + self.assertValidAuthenticationResponse(r) + + def test_get_tenants_for_token(self): + r = self.public_request(path='/v2.0/tenants', + token=self.get_scoped_token()) + self.assertValidTenantListResponse(r) + + def test_validate_token(self): + token = self.get_scoped_token() + r = self.admin_request(path='/v2.0/tokens/%(token_id)s' % { + 'token_id': token, + }, + token=token) + self.assertValidAuthenticationResponse(r) + + def test_validate_token_head(self): + """The same call as above, except using HEAD. + + There's no response to validate here, but this is included for the + sake of completely covering the core API. + + """ + raise nose.exc.SkipTest('Blocked by bug 933587') + + token = self.get_scoped_token() + self.admin_request(method='HEAD', path='/v2.0/tokens/%(token_id)s' % { + 'token_id': token, + }, + token=token) + + def test_endpoints(self): + raise nose.exc.SkipTest('Blocked by bug 933555') + + token = self.get_scoped_token() + r = self.admin_request(path='/v2.0/tokens/%(token_id)s/endpoints' % { + 'token_id': token, + }, + token=token) + self.assertValidTokenCatalogResponse(r) + + def test_get_tenant(self): + token = self.get_scoped_token() + r = self.admin_request(path='/v2.0/tenants/%(tenant_id)s' % { + 'tenant_id': self.tenant_bar['id'], + }, + token=token) + self.assertValidTenantResponse(r) + + def test_get_user_roles(self): + raise nose.exc.SkipTest('Blocked by bug 933565') + + token = self.get_scoped_token() + r = self.admin_request(path='/v2.0/users/%(user_id)s/roles' % { + 'user_id': self.user_foo['id'], + }, + token=token) + self.assertValidRoleListResponse(r) + + def test_get_user_roles_with_tenant(self): + token = self.get_scoped_token() + r = self.admin_request( + path='/v2.0/tenants/%(tenant_id)s/users/%(user_id)s/roles' % { + 'tenant_id': self.tenant_bar['id'], + 'user_id': self.user_foo['id'], + }, + token=token) + self.assertValidRoleListResponse(r) + + def test_get_user(self): + token = self.get_scoped_token() + r = self.admin_request(path='/v2.0/users/%(user_id)s' % { + 'user_id': self.user_foo['id'], + }, + token=token) + self.assertValidUserResponse(r) + + def test_error_response(self): + """This triggers assertValidErrorResponse by convention.""" + self.public_request(path='/v2.0/tenants', expected_status=401) + + +class JsonTestCase(RestfulTestCase, CoreApiTests): + content_type = 'json' + + def _get_token_id(self, r): + """Applicable only to JSON.""" + return r.body['access']['token']['id'] + + def assertValidErrorResponse(self, r): + self.assertIsNotNone(r.body.get('error')) + self.assertValidError(r.body['error']) + self.assertEqual(r.body['error']['code'], r.status) + + def assertValidAuthenticationResponse(self, r): + self.assertIsNotNone(r.body.get('access')) + self.assertIsNotNone(r.body['access'].get('token')) + self.assertIsNotNone(r.body['access'].get('user')) + + # validate token + self.assertIsNotNone(r.body['access']['token'].get('id')) + self.assertIsNotNone(r.body['access']['token'].get('expires')) + tenant = r.body['access']['token'].get('tenant') + if tenant is not None: + # validate tenant + self.assertIsNotNone(tenant.get('id')) + self.assertIsNotNone(tenant.get('name')) + + # validate user + self.assertIsNotNone(r.body['access']['user'].get('id')) + self.assertIsNotNone(r.body['access']['user'].get('name')) + + # validate service catalog + if r.body['access'].get('serviceCatalog') is not None: + self.assertTrue(len(r.body['access']['serviceCatalog'])) + for service in r.body['access']['serviceCatalog']: + # validate service + self.assertIsNotNone(service.get('name')) + self.assertIsNotNone(service.get('type')) + + # services contain at least one endpoint + self.assertIsNotNone(service.get('endpoints')) + self.assertTrue(len(service['endpoints'])) + for endpoint in service['endpoints']: + # validate service endpoint + self.assertIsNotNone(endpoint.get('publicURL')) + + def assertValidTenantListResponse(self, r): + self.assertIsNotNone(r.body.get('tenants')) + self.assertTrue(len(r.body['tenants'])) + for tenant in r.body['tenants']: + self.assertValidTenant(tenant) + self.assertIsNotNone(tenant.get('enabled')) + self.assertIn(tenant.get('enabled'), [True, False]) + + def assertValidUserResponse(self, r): + self.assertIsNotNone(r.body.get('user')) + self.assertValidUser(r.body['user']) + + def assertValidTenantResponse(self, r): + self.assertIsNotNone(r.body.get('tenant')) + self.assertValidTenant(r.body['tenant']) + + def assertValidRoleListResponse(self, r): + self.assertIsNotNone(r.body.get('roles')) + self.assertTrue(len(r.body['roles'])) + for role in r.body['roles']: + self.assertValidRole(role) + + def assertValidMultipleChoiceResponse(self, r): + self.assertIsNotNone(r.body.get('versions')) + self.assertIsNotNone(r.body['versions'].get('values')) + self.assertTrue(len(r.body['versions']['values'])) + for version in r.body['versions']['values']: + self.assertIsNotNone(version.get('id')) + self.assertIsNotNone(version.get('status')) + self.assertIsNotNone(version.get('updated')) + self.assertIsNotNone(version.get('links')) + self.assertTrue(len(version.get('links'))) + for link in version.get('links'): + self.assertIsNotNone(link.get('rel')) + self.assertIsNotNone(link.get('href')) + self.assertIsNotNone(version.get('media-types')) + self.assertTrue(len(version.get('media-types'))) + for media in version.get('media-types'): + self.assertIsNotNone(media.get('base')) + self.assertIsNotNone(media.get('type')) + + def assertValidVersionResponse(self, r): + raise NotImplementedError() + + +class XmlTestCase(RestfulTestCase, CoreApiTests): + xmlns = 'http://docs.openstack.org/identity/api/v2.0' + content_type = 'xml' + + def _get_token_id(self, r): + return r.body.find(self._tag('token')).get('id') + + def _tag(self, tag_name, xmlns=None): + """Helper method to build an namespaced element name.""" + return '{%(ns)s}%(tag)s' % {'ns': xmlns or self.xmlns, 'tag': tag_name} + + def assertValidErrorResponse(self, r): + xml = r.body + self.assertEqual(xml.tag, self._tag('error')) + + self.assertValidError(xml) + self.assertEqual(xml.get('code'), str(r.status)) + + def assertValidMultipleChoiceResponse(self, r): + xml = r.body + self.assertEqual(xml.tag, self._tag('versions')) + + self.assertTrue(len(xml.findall(self._tag('version')))) + for version in xml.findall(self._tag('version')): + # validate service endpoint + self.assertIsNotNone(version.get('id')) + self.assertIsNotNone(version.get('status')) + self.assertIsNotNone(version.get('updated')) + + self.assertTrue(len(version.findall(self._tag('link')))) + for link in version.findall(self._tag('link')): + self.assertIsNotNone(link.get('rel')) + self.assertIsNotNone(link.get('href')) + + media_types = version.find(self._tag('media-types')) + self.assertIsNotNone(media_types) + self.assertTrue(len(media_types.findall(self._tag('media-type')))) + for media in media_types.findall(self._tag('media-type')): + self.assertIsNotNone(media.get('base')) + self.assertIsNotNone(media.get('type')) + + def assertValidVersionResponse(self, r): + raise NotImplementedError() + + def assertValidTokenCatalogResponse(self, r): + xml = r.body + self.assertEqual(xml.tag, self._tag('endpoints')) + + self.assertTrue(len(xml.findall(self._tag('endpoint')))) + for endpoint in xml.findall(self._tag('endpoint')): + self.assertIsNotNone(endpoint.get('publicUrl')) + + def assertValidTenantResponse(self, r): + xml = r.body + self.assertEqual(xml.tag, self._tag('tenant')) + + self.assertValidTenant(xml) + + def assertValidUserResponse(self, r): + xml = r.body + self.assertEqual(xml.tag, self._tag('user')) + + self.assertValidUser(xml) + + def assertValidRoleListResponse(self, r): + xml = r.body + self.assertEqual(xml.tag, self._tag('roles')) + + self.assertTrue(len(r.body.findall(self._tag('role')))) + for role in r.body.findall(self._tag('role')): + self.assertValidRole(role) + + def assertValidAuthenticationResponse(self, r): + xml = r.body + self.assertEqual(xml.tag, self._tag('access')) + + # validate token + token = xml.find(self._tag('token')) + self.assertIsNotNone(token) + self.assertIsNotNone(token.get('id')) + self.assertIsNotNone(token.get('expires')) + tenant = token.find(self._tag('tenant')) + if tenant is not None: + # validate tenant + self.assertValidTenant(tenant) + self.assertIn(tenant.get('enabled'), ['true', 'false']) + + user = xml.find(self._tag('user')) + self.assertIsNotNone(user) + self.assertIsNotNone(user.get('id')) + self.assertIsNotNone(user.get('name')) + + serviceCatalog = xml.find(self._tag('serviceCatalog')) + if serviceCatalog is not None: + self.assertTrue(len(serviceCatalog.findall(self._tag('service')))) + for service in serviceCatalog.findall(self._tag('service')): + # validate service + self.assertIsNotNone(service.get('name')) + self.assertIsNotNone(service.get('type')) + + # services contain at least one endpoint + self.assertTrue(len(service)) + for endpoint in service.findall(self._tag('endpoint')): + # validate service endpoint + self.assertIsNotNone(endpoint.get('publicURL')) + + def assertValidTenantListResponse(self, r): + xml = r.body + self.assertEqual(xml.tag, self._tag('tenants')) + + self.assertTrue(len(r.body)) + for tenant in r.body.findall(self._tag('tenant')): + self.assertValidTenant(tenant) + self.assertIn(tenant.get('enabled'), ['true', 'false']) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 01d6c8ed..d46348a4 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -14,6 +14,8 @@ # License for the specific language governing permissions and limitations # under the License. +import json + import webob from keystone import config @@ -25,15 +27,23 @@ CONF = config.CONF def make_request(**kwargs): + accept = kwargs.pop('accept', None) method = kwargs.pop('method', 'GET') body = kwargs.pop('body', None) req = webob.Request.blank('/', **kwargs) req.method = method if body is not None: req.body = body + if accept is not None: + req.accept = accept return req +def make_response(**kwargs): + body = kwargs.pop('body', None) + return webob.Response(body) + + class TokenAuthMiddlewareTest(test.TestCase): def test_request(self): req = make_request() @@ -97,3 +107,51 @@ class JsonBodyMiddlewareTest(test.TestCase): middleware.JsonBodyMiddleware(None).process_request(req) params = req.environ.get(middleware.PARAMS_ENV, {}) self.assertEqual(params, {}) + + +class XmlBodyMiddlewareTest(test.TestCase): + def test_client_wants_xml_back(self): + """Clients requesting XML should get what they ask for.""" + body = '{"container": {"attribute": "value"}}' + req = make_request(body=body, method='POST', accept='application/xml') + middleware.XmlBodyMiddleware(None).process_request(req) + resp = make_response(body=body) + middleware.XmlBodyMiddleware(None).process_response(req, resp) + self.assertEqual(resp.content_type, 'application/xml') + + def test_client_wants_json_back(self): + """Clients requesting JSON should definitely not get XML back.""" + body = '{"container": {"attribute": "value"}}' + req = make_request(body=body, method='POST', accept='application/json') + middleware.XmlBodyMiddleware(None).process_request(req) + resp = make_response(body=body) + middleware.XmlBodyMiddleware(None).process_response(req, resp) + self.assertNotIn('application/xml', resp.content_type) + + def test_client_fails_to_specify_accept(self): + """If client does not specify an Accept header, default to JSON.""" + body = '{"container": {"attribute": "value"}}' + req = make_request(body=body, method='POST') + middleware.XmlBodyMiddleware(None).process_request(req) + resp = make_response(body=body) + middleware.XmlBodyMiddleware(None).process_response(req, resp) + self.assertNotIn('application/xml', resp.content_type) + + def test_xml_replaced_by_json(self): + """XML requests should be replaced by JSON requests.""" + req = make_request( + body='<container><element attribute="value" /></container>', + content_type='application/xml', + method='POST') + middleware.XmlBodyMiddleware(None).process_request(req) + self.assertTrue(req.content_type, 'application/json') + self.assertTrue(json.loads(req.body)) + + def test_json_unnaffected(self): + """JSON-only requests should be unnaffected by the XML middleware.""" + content_type = 'application/json' + body = '{"container": {"attribute": "value"}}' + req = make_request(body=body, content_type=content_type, method='POST') + middleware.XmlBodyMiddleware(None).process_request(req) + self.assertEqual(req.body, body) + self.assertEqual(req.content_type, content_type) diff --git a/tests/test_serializer.py b/tests/test_serializer.py new file mode 100644 index 00000000..0be5ca4c --- /dev/null +++ b/tests/test_serializer.py @@ -0,0 +1,155 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +import re + +from keystone import test +from keystone.common import serializer + + +class XmlSerializerTestCase(test.TestCase): + def assertEqualIgnoreWhitespace(self, a, b): + """Splits two strings into lists and compares them. + + This provides easy-to-read failures from nose. + + """ + try: + self.assertEqual(a, b) + except: + a = re.sub('[ \n]+', ' ', a).strip().split() + b = re.sub('[ \n]+', ' ', b).strip().split() + self.assertEqual(a, b) + + def assertSerializeDeserialize(self, d, xml, xmlns=None): + self.assertEqualIgnoreWhitespace(serializer.to_xml(d, xmlns), xml) + self.assertEqual(serializer.from_xml(xml), d) + + # operations should be invertable + self.assertEqual( + serializer.from_xml(serializer.to_xml(d, xmlns)), + d) + self.assertEqualIgnoreWhitespace( + serializer.to_xml(serializer.from_xml(xml), xmlns), + xml) + + def test_none(self): + d = None + xml = None + + self.assertSerializeDeserialize(d, xml) + + def test_auth_request(self): + d = { + "auth": { + "passwordCredentials": { + "username": "test_user", + "password": "mypass" + }, + "tenantName": "customer-x" + } + } + + xml = """ + <?xml version="1.0" encoding="UTF-8"?> + <auth xmlns="http://docs.openstack.org/identity/api/v2.0" + tenantName="customer-x"> + <passwordCredentials + username="test_user" + password="mypass"/> + </auth> + """ + + self.assertSerializeDeserialize(d, xml) + + def test_role_crud(self): + d = { + "role": { + "id": "123", + "name": "Guest", + "description": "Guest Access" + } + } + + # TODO(dolph): examples show this description as an attribute? + xml = """ + <?xml version="1.0" encoding="UTF-8"?> + <role xmlns="http://docs.openstack.org/identity/api/v2.0" + id="123" + name="Guest"> + <description>Guest Access</description> + </role> + """ + + self.assertSerializeDeserialize(d, xml) + + def test_service_crud(self): + xmlns = "http://docs.openstack.org/identity/api/ext/OS-KSADM/v1.0" + + d = { + # FIXME(dolph): should be... + # "OS-KSADM:service": { + "service": { + "id": "123", + "name": "nova", + "type": "compute", + "description": "OpenStack Compute Service" + } + } + + # TODO(dolph): examples show this description as an attribute? + xml = """ + <?xml version="1.0" encoding="UTF-8"?> + <service + xmlns="%(xmlns)s" + type="compute" + id="123" + name="nova"> + <description>OpenStack Compute Service</description> + </service> + """ % {'xmlns': xmlns} + + self.assertSerializeDeserialize(d, xml, xmlns=xmlns) + + def test_tenant_crud(self): + d = { + "tenant": { + "id": "1234", + "name": "ACME corp", + "description": "A description...", + "enabled": True + } + } + + xml = """ + <?xml version="1.0" encoding="UTF-8"?> + <tenant + xmlns="http://docs.openstack.org/identity/api/v2.0" + enabled="true" + id="1234" + name="ACME corp"> + <description>A description...</description> + </tenant> + """ + + self.assertSerializeDeserialize(d, xml) + + def test_values_list(self): + d = { + "objects": { + "values": [{ + "attribute": "value1", + }, { + "attribute": "value2", + }] + } + } + + xml = """ + <?xml version="1.0" encoding="UTF-8"?> + <objects xmlns="http://docs.openstack.org/identity/api/v2.0"> + <object attribute="value1"/> + <object attribute="value2"/> + </objects> + """ + + self.assertEqualIgnoreWhitespace(serializer.to_xml(d), xml) diff --git a/tests/test_versions.py b/tests/test_versions.py index 0743cbfa..ebbd8451 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -41,32 +41,43 @@ class VersionTestCase(test.TestCase): data = json.loads(resp.body) expected = { "versions": { - "values": [{ - "id": "v2.0", - "status": "beta", - "updated": "2011-11-19T00:00:00Z", - "links": [{ - "rel": "self", - "href": ("http://localhost:%s/v2.0/" % - CONF.public_port), - }, { - "rel": "describedby", - "type": "text/html", - "href": "http://docs.openstack.org/api/openstack-" - "identity-service/2.0/content/" - }, { - "rel": "describedby", - "type": "application/pdf", - "href": "http://docs.openstack.org/api/openstack-" - "identity-service/2.0/identity-dev-guide-" - "2.0.pdf" - }], - "media-types": [{ - "base": "application/json", - "type": "application/vnd.openstack.identity-v2.0" - "+json" - }] - }] + "values": [ + { + "id": "v2.0", + "status": "beta", + "updated": "2011-11-19T00:00:00Z", + "links": [ + { + "rel": "self", + "href": "http://localhost:%s/v2.0/" % + CONF.public_port, + }, { + "rel": "describedby", + "type": "text/html", + "href": "http://docs.openstack.org/api/" + "openstack-identity-service/2.0/" + "content/" + }, { + "rel": "describedby", + "type": "application/pdf", + "href": "http://docs.openstack.org/api/" + "openstack-identity-service/2.0/" + "identity-dev-guide-2.0.pdf" + } + ], + "media-types": [ + { + "base": "application/json", + "type": "application/" + "vnd.openstack.identity-v2.0+json" + }, { + "base": "application/xml", + "type": "application/" + "vnd.openstack.identity-v2.0+xml" + } + ] + } + ] } } self.assertEqual(data, expected) @@ -78,32 +89,43 @@ class VersionTestCase(test.TestCase): data = json.loads(resp.body) expected = { "versions": { - "values": [{ - "id": "v2.0", - "status": "beta", - "updated": "2011-11-19T00:00:00Z", - "links": [{ - "rel": "self", - "href": ("http://localhost:%s/v2.0/" % - CONF.admin_port), - }, { - "rel": "describedby", - "type": "text/html", - "href": "http://docs.openstack.org/api/openstack-" - "identity-service/2.0/content/" - }, { - "rel": "describedby", - "type": "application/pdf", - "href": "http://docs.openstack.org/api/openstack-" - "identity-service/2.0/identity-dev-guide-" - "2.0.pdf" - }], - "media-types": [{ - "base": "application/json", - "type": "application/vnd.openstack.identity-v2.0" - "+json" - }] - }] + "values": [ + { + "id": "v2.0", + "status": "beta", + "updated": "2011-11-19T00:00:00Z", + "links": [ + { + "rel": "self", + "href": "http://localhost:%s/v2.0/" % + CONF.admin_port, + }, { + "rel": "describedby", + "type": "text/html", + "href": "http://docs.openstack.org/api/" + "openstack-identity-service/2.0/" + "content/" + }, { + "rel": "describedby", + "type": "application/pdf", + "href": "http://docs.openstack.org/api/" + "openstack-identity-service/2.0/" + "identity-dev-guide-2.0.pdf" + } + ], + "media-types": [ + { + "base": "application/json", + "type": "application/" + "vnd.openstack.identity-v2.0+json" + }, { + "base": "application/xml", + "type": "application/" + "vnd.openstack.identity-v2.0+xml" + } + ] + } + ] } } self.assertEqual(data, expected) diff --git a/tools/pip-requires b/tools/pip-requires index 04f7ef5f..8b6d0ca0 100644 --- a/tools/pip-requires +++ b/tools/pip-requires @@ -8,6 +8,7 @@ routes sqlalchemy sqlalchemy-migrate passlib +lxml # for python-novaclient prettytable diff --git a/tools/pip-requires-test b/tools/pip-requires-test index d968ed94..fa7457e0 100644 --- a/tools/pip-requires-test +++ b/tools/pip-requires-test @@ -9,6 +9,7 @@ sqlalchemy sqlalchemy-migrate passlib python-memcached +lxml # keystonelight testing dependencies nose |