Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

XMLAdapterMixin implementation with xmltodict #87

Merged
merged 4 commits into from
May 24, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/source/buildingawrapper.rst
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ You might want to use one of the following mixins to help you with data format h

- ``FormAdapterMixin``
- ``JSONAdapterMixin``
- ``XMLAdapterMixin``


Exceptions
Expand Down Expand Up @@ -99,3 +100,25 @@ You can implement the ```refresh_authentication``` and ```is_authentication_expi

def refresh_authentication(self, api_params, *args, **kwargs):
...


XMLAdapterMixin Configuration (only if required)
------------------------------------------------

Additionally, the XMLAdapterMixin accepts configuration keyword arguments to be passed to the xmltodict library during parsing and unparsing by prefixing the xmltodict keyword with ``xmltodict_parse__`` or ``xmltodict_unparse`` respectively. These parameters should be configured so that the end-user has a consistent experience across multiple Tapioca wrappers irrespective of various API requirements from wrapper to wrapper.

Note that the end-user should **not** need to modify these keyword arguments themselves. See xmltodict `docs <http://xmltodict.readthedocs.org/en/latest/>`_ and `source <https://github.com/martinblech/xmltodict>`_ for valid parameters.

Users should be able to construct dictionaries as defined by the xmltodict library, and responses should be returned in the canonical format.

Example XMLAdapterMixin configuration keywords:

.. code-block:: python

class MyXMLClientAdapter(XMLAdapterMixin, TapiocaAdapter):
...
def get_request_kwargs(self, api_params, *args, **kwargs):
...
# omits XML declaration when constructing requests from dictionary
kwargs['xmltodict_unparse__full_document'] = False
...
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
'requests>=2.6,<2.8',
'arrow>=0.6.0,<0.7',
'six>=1',
'xmltodict>=0.9.2'
]
test_requirements = [
'responses>=0.5',
Expand Down
43 changes: 43 additions & 0 deletions tapioca/adapters.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# coding: utf-8

import json
import xmltodict
from collections import Mapping

from .tapioca import TapiocaInstantiator
from .exceptions import (
Expand Down Expand Up @@ -115,3 +117,44 @@ def format_data_to_request(self, data):
def response_to_native(self, response):
if response.content.strip():
return response.json()


class XMLAdapterMixin(object):

def _input_branches_to_xml_bytestring(self, data):
if isinstance(data, Mapping):
return xmltodict.unparse(
data, **self._xmltodict_unparse_kwargs).encode('utf-8')
try:
return data.encode('utf-8')
except Exception as e:
raise type(e)('Format not recognized, please enter an XML as string or a dictionary'
'in xmltodict spec: \n%s' % e.message)

def get_request_kwargs(self, api_params, *args, **kwargs):
# stores kwargs prefixed with 'xmltodict_unparse__' for use by xmltodict.unparse
self._xmltodict_unparse_kwargs = {k[len('xmltodict_unparse__'):]: kwargs.pop(k)
for k in kwargs.copy().keys()
if k.startswith('xmltodict_unparse__')}
# stores kwargs prefixed with 'xmltodict_parse__' for use by xmltodict.parse
self._xmltodict_parse_kwargs = {k[len('xmltodict_parse__'):]: kwargs.pop(k)
for k in kwargs.copy().keys()
if k.startswith('xmltodict_parse__')}

arguments = super(XMLAdapterMixin, self).get_request_kwargs(
api_params, *args, **kwargs)

if 'headers' not in arguments:
arguments['headers'] = {}
arguments['headers']['Content-Type'] = 'application/xml'
return arguments

def format_data_to_request(self, data):
if data:
return self._input_branches_to_xml_bytestring(data)

def response_to_native(self, response):
if response.content.strip():
if 'xml' in response.headers['content-type']:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to verify this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@filipeximenes the cause for adding this check is a cryptic ExpatError that is thrown whenever the server doesn't return valid XML.

The Google Sheets endpoint I'm testing with returns a string when it encounters an error condition (e.g. 400, 401 error), and Tapioca's ClientError is interrupted and replaced with the ExpatError:

ExpatError: syntax error: line 1, column 0

For non-error conditions, I thought it best to return the text of the response so that an end-user could inspect it, instead of returning nothing. We could use a try / except statement around xmltodict.parse() and handle any exception that xmltodict throws, rather than indirectly handling it through checking the response headers, but we need something here.

return xmltodict.parse(response.content, **self._xmltodict_parse_kwargs)
return {'text': response.text}
10 changes: 9 additions & 1 deletion tests/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import unicode_literals

from tapioca.adapters import (
TapiocaAdapter, JSONAdapterMixin,
TapiocaAdapter, JSONAdapterMixin, XMLAdapterMixin,
generate_wrapper_from_adapter)
from tapioca.serializers import SimpleSerializer

Expand Down Expand Up @@ -65,3 +65,11 @@ def refresh_authentication(self, api_params, *args, **kwargs):


TokenRefreshClient = generate_wrapper_from_adapter(TokenRefreshClientAdapter)


class XMLClientAdapter(XMLAdapterMixin, TapiocaAdapter):
api_root = 'https://api.test.com'
resource_mapping = RESOURCE_MAPPING


XMLClient = generate_wrapper_from_adapter(XMLClientAdapter)
93 changes: 92 additions & 1 deletion tests/test_tapioca.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
import responses
import arrow
import json
import xmltodict
from collections import OrderedDict
from decimal import Decimal

from tapioca.tapioca import TapiocaClient
from tapioca.serializers import SimpleSerializer
from tapioca.exceptions import ClientError

from tests.client import TesterClient, SerializerClient, TokenRefreshClient
from tests.client import TesterClient, SerializerClient, TokenRefreshClient, XMLClient


class TestTapiocaClient(unittest.TestCase):
Expand Down Expand Up @@ -496,3 +498,92 @@ def request_callback(request):

# refresh_authentication method should be able to update api_params
self.assertEqual(response._api_params['token'], 'new_token')


class TestXMLRequests(unittest.TestCase):

def setUp(self):
self.wrapper = XMLClient()

@responses.activate
def test_xml_post_string(self):
responses.add(responses.POST, self.wrapper.test().data,
body='Any response', status=200, content_type='application/json')

data = ('<tag1 attr1="val1">'
'<tag2>text1</tag2>'
'<tag3>text2</tag3>'
'</tag1>')

self.wrapper.test().post(data=data)

request_body = responses.calls[0].request.body

self.assertEqual(request_body, data.encode('utf-8'))

@responses.activate
def test_xml_post_dict(self):
responses.add(responses.POST, self.wrapper.test().data,
body='Any response', status=200, content_type='application/json')

data = OrderedDict([
('tag1', OrderedDict([
('@attr1', 'val1'), ('tag2', 'text1'), ('tag3', 'text2')
]))
])

self.wrapper.test().post(data=data)

request_body = responses.calls[0].request.body

self.assertEqual(request_body, xmltodict.unparse(data).encode('utf-8'))

@responses.activate
def test_xml_post_dict_passes_unparse_param(self):
responses.add(responses.POST, self.wrapper.test().data,
body='Any response', status=200, content_type='application/json')

data = OrderedDict([
('tag1', OrderedDict([
('@attr1', 'val1'), ('tag2', 'text1'), ('tag3', 'text2')
]))
])

self.wrapper.test().post(data=data, xmltodict_unparse__full_document=False)

request_body = responses.calls[0].request.body

self.assertEqual(request_body, xmltodict.unparse(
data, full_document=False).encode('utf-8'))

@responses.activate
def test_xml_returns_text_if_response_not_xml(self):
responses.add(responses.POST, self.wrapper.test().data,
body='Any response', status=200, content_type='any content')

data = OrderedDict([
('tag1', OrderedDict([
('@attr1', 'val1'), ('tag2', 'text1'), ('tag3', 'text2')
]))
])

response = self.wrapper.test().post(data=data)

self.assertEqual('Any response', response().data['text'])

@responses.activate
def test_xml_post_dict_returns_dict_if_response_xml(self):
xml_body = '<tag1 attr1="val1">text1</tag1>'
responses.add(responses.POST, self.wrapper.test().data,
body=xml_body, status=200,
content_type='application/xml')

data = OrderedDict([
('tag1', OrderedDict([
('@attr1', 'val1'), ('tag2', 'text1'), ('tag3', 'text2')
]))
])

response = self.wrapper.test().post(data=data)

self.assertEqual(response().data, xmltodict.parse(xml_body))