You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

146 lines
5.7 KiB

4 years ago
  1. import re
  2. import requests
  3. from lxml import etree
  4. from .util import _getLogger
  5. SOAP_TIMEOUT = 30
  6. NS_SOAP_ENV = 'http://schemas.xmlsoap.org/soap/envelope/'
  7. NS_UPNP_ERR = 'urn:schemas-upnp-org:control-1-0'
  8. ENCODING_STYLE = 'http://schemas.xmlsoap.org/soap/encoding/'
  9. ENCODING = 'utf-8'
  10. class SOAPError(Exception):
  11. pass
  12. class SOAPProtocolError(Exception):
  13. pass
  14. class SOAP(object):
  15. """SOAP (Simple Object Access Protocol) implementation
  16. This class defines a simple SOAP client.
  17. """
  18. def __init__(self, url, service_type):
  19. self.url = url
  20. self.service_type = service_type
  21. # FIXME: Use urlparse for this:
  22. self._host = self.url.split('//', 1)[1].split('/', 1)[0] # Get hostname portion of url
  23. self._log = _getLogger('SOAP')
  24. def _extract_upnperror(self, err_xml):
  25. """
  26. Extract the error code and error description from an error returned by the device.
  27. """
  28. nsmap = {'s': list(err_xml.nsmap.values())[0]}
  29. fault_str = err_xml.findtext(
  30. 's:Body/s:Fault/faultstring', namespaces=nsmap)
  31. try:
  32. err = err_xml.xpath(
  33. 's:Body/s:Fault/detail/*[name()="%s"]' % fault_str, namespaces=nsmap)[0]
  34. except IndexError:
  35. msg = 'Tag with name of %r was not found in the error response.' % fault_str
  36. self._log.debug(
  37. msg + '\n' + etree.tostring(err_xml, pretty_print=True).decode('utf8'))
  38. raise SOAPProtocolError(msg)
  39. err_code = err.findtext('errorCode', namespaces=err.nsmap)
  40. err_desc = err.findtext('errorDescription', namespaces=err.nsmap)
  41. if err_code is None or err_desc is None:
  42. msg = 'Tags errorCode or errorDescription were not found in the error response.'
  43. self._log.debug(
  44. msg + '\n' + etree.tostring(err_xml, pretty_print=True).decode('utf8'))
  45. raise SOAPProtocolError(msg)
  46. return int(err_code), err_desc
  47. @staticmethod
  48. def _remove_extraneous_xml_declarations(xml_str):
  49. """
  50. Sometimes devices return XML with more than one XML declaration in, such as when returning
  51. their own XML config files. This removes the extra ones and preserves the first one.
  52. """
  53. xml_declaration = ''
  54. if xml_str.startswith('<?xml'):
  55. xml_declaration, xml_str = xml_str.split('?>', maxsplit=1)
  56. xml_declaration += '?>'
  57. xml_str = re.sub(r'<\?xml.*?\?>', '', xml_str, flags=re.I)
  58. return xml_declaration + xml_str
  59. def call(self, action_name, arg_in=None, http_auth=None, http_headers=None):
  60. """
  61. Construct the XML and make the call to the device. Parse the response values into a dict.
  62. """
  63. if arg_in is None:
  64. arg_in = {}
  65. soap_env = '{%s}' % NS_SOAP_ENV
  66. m = '{%s}' % self.service_type
  67. root = etree.Element(soap_env+'Envelope', nsmap={'SOAP-ENV': NS_SOAP_ENV})
  68. root.attrib[soap_env+'encodingStyle'] = ENCODING_STYLE
  69. body = etree.SubElement(root, soap_env+'Body')
  70. action = etree.SubElement(body, m+action_name, nsmap={'m': self.service_type})
  71. for key, value in arg_in.items():
  72. etree.SubElement(action, key).text = str(value)
  73. body = etree.tostring(root, encoding=ENCODING, xml_declaration=True)
  74. headers = {
  75. 'SOAPAction': '"%s#%s"' % (self.service_type, action_name),
  76. 'Host': self._host,
  77. 'Content-Type': 'text/xml',
  78. 'Content-Length': str(len(body)),
  79. }
  80. headers.update(http_headers or {})
  81. try:
  82. resp = requests.post(
  83. self.url,
  84. body,
  85. headers=headers,
  86. timeout=SOAP_TIMEOUT,
  87. auth=http_auth
  88. )
  89. resp.raise_for_status()
  90. except requests.exceptions.HTTPError as exc:
  91. # If the body of the error response contains XML then it should be a UPnP error,
  92. # otherwise reraise the HTTPError.
  93. try:
  94. err_xml = etree.fromstring(exc.response.content)
  95. except etree.XMLSyntaxError:
  96. raise exc
  97. raise SOAPError(*self._extract_upnperror(err_xml))
  98. xml_str = resp.content.strip()
  99. try:
  100. xml = etree.fromstring(xml_str)
  101. except etree.XMLSyntaxError:
  102. # Try removing any extra XML declarations in case there are more than one.
  103. # This sometimes happens when a device sends its own XML config files.
  104. xml = etree.fromstring(self._remove_extraneous_xml_declarations(xml_str))
  105. except ValueError:
  106. # This can occur when requests returns a `str` (unicode) but there's also an XML
  107. # declaration, which lxml doesn't like.
  108. xml = etree.fromstring(xml_str.encode('utf8'))
  109. response = xml.find(".//{%s}%sResponse" % (self.service_type, action_name))
  110. if response is None:
  111. msg = ('Returned XML did not include an element which matches namespace %r and tag name'
  112. ' \'%sResponse\'.' % (self.service_type, action_name))
  113. self._log.debug(msg + '\n' + etree.tostring(xml, pretty_print=True).decode('utf8'))
  114. raise SOAPProtocolError(msg)
  115. # Sometimes devices return XML strings as their argument values without escaping them with
  116. # CDATA. This checks to see if the argument has been parsed as XML and un-parses it if so.
  117. ret = {}
  118. for arg in response.getchildren():
  119. children = arg.getchildren()
  120. if children:
  121. ret[arg.tag] = b"\n".join(etree.tostring(x) for x in children)
  122. else:
  123. ret[arg.tag] = arg.text
  124. return ret