Skip to content

Commit bdaf350

Browse files
mamiksikfilak-sap
authored andcommitted
Add support for whitelisted and custom XML namespaces
Recognizing that many xml namespaces ae same despite different hosting url we added whitelisted urls for which we granite support. Also we added option to pass custom dict with namespaces in case non of whitelisted is used by your data source. --- Jakub Filak <[email protected]> ---- This commit adds support for XML Namespaces used in #27
1 parent 6a533fc commit bdaf350

File tree

7 files changed

+231
-34
lines changed

7 files changed

+231
-34
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
# 1.2.3
4+
* dca01a28 - model: add support for whitelisted and custom namespaces - Martin Miksik
5+
36
# 1.2.2
47
* ed1ae8a - model: fix namespace parsing - Jakub Filak
58

docs/usage/initialization.rst

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,27 @@ Basic initialization which is going to work for everybody:
2222
northwind = pyodata.Client(SERVICE_URL, requests.Session())
2323
2424
25+
Set custom namespaces
26+
---------------------
27+
28+
Let's assume you need to work with a service which uses namespaces not directly supported by this library e. g. ones
29+
hosted on private urls such as *customEdmxUrl.com* and *customEdmUrl.com*:
30+
31+
.. code-block:: python
32+
33+
import pyodata
34+
import requests
35+
36+
SERVICE_URL = 'http://services.odata.org/V2/Northwind/Northwind.svc/'
37+
38+
namespaces = {
39+
'edmx': "customEdmxUrl.com"
40+
'edm': 'customEdmUrl.com'
41+
}
42+
43+
northwind = pyodata.Client(SERVICE_URL, requests.Session(), namespaces=namespaces)
44+
45+
2546
Get the service proxy client for an OData service requiring authentication
2647
--------------------------------------------------------------------------
2748

pyodata/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class Client:
1414

1515
ODATA_VERSION_2 = 2
1616

17-
def __new__(cls, url, connection, odata_version=ODATA_VERSION_2):
17+
def __new__(cls, url, connection, odata_version=ODATA_VERSION_2, namespaces=None):
1818
"""Create instance of the OData Client for given URL"""
1919

2020
logger = logging.getLogger('pyodata.client')
@@ -44,7 +44,7 @@ def __new__(cls, url, connection, odata_version=ODATA_VERSION_2):
4444

4545
# create model instance from received metadata
4646
logger.info('Creating OData Schema (version: %d)', odata_version)
47-
schema = pyodata.v2.model.schema_from_xml(resp.content)
47+
schema = pyodata.v2.model.schema_from_xml(resp.content, namespaces=namespaces)
4848

4949
# create service instance based on model we have
5050
logger.info('Creating OData Service (version: %d)', odata_version)

pyodata/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ class PyODataModelError(PyODataException):
1212
"""Raised when model error occurs"""
1313

1414

15+
class PyODataParserError(PyODataException):
16+
"""Raised when parser error occurs"""
17+
18+
1519
class ExpressionError(PyODataException):
1620
"""Raise when runtime logical expression error occurs"""
1721

pyodata/v2/model.py

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from lxml import etree
1818

19-
from pyodata.exceptions import PyODataException, PyODataModelError
19+
from pyodata.exceptions import PyODataException, PyODataModelError, PyODataParserError
2020

2121
LOGGER_NAME = 'pyodata.model'
2222

@@ -2029,12 +2029,24 @@ def sap_attribute_get_bool(node, attr, default):
20292029
raise TypeError('Not a bool attribute: {0} = {1}'.format(attr, value))
20302030

20312031

2032+
EDMX_WHITELIST = [
2033+
'http://schemas.microsoft.com/ado/2007/06/edmx',
2034+
'http://docs.oasis-open.org/odata/ns/edmx',
2035+
]
2036+
2037+
2038+
EDM_WHITELIST = [
2039+
'http://schemas.microsoft.com/ado/2008/09/edm',
2040+
'http://docs.oasis-open.org/odata/ns/edm'
2041+
]
2042+
20322043
NAMESPACES = {
20332044
'd': 'http://schemas.microsoft.com/ado/2007/08/dataservices',
20342045
'm': 'http://schemas.microsoft.com/ado/2007/08/dataservices/metadata',
20352046
'sap': 'http://www.sap.com/Protocols/SAPData',
2036-
'edmx': 'http://schemas.microsoft.com/ado/2007/06/edmx',
2037-
'edm': 'http://schemas.microsoft.com/ado/2008/09/edm'
2047+
'edmx': None,
2048+
'edm': None
2049+
20382050
}
20392051

20402052

@@ -2064,21 +2076,56 @@ def __init__(self):
20642076
super(Edmx, self).__init__()
20652077

20662078
@staticmethod
2067-
def parse(metadata_xml):
2079+
def parse(metadata_xml, namespaces=None):
20682080
""" Build model from the XML metadata"""
20692081
if isinstance(metadata_xml, str):
20702082
mdf = io.StringIO(metadata_xml)
20712083
elif isinstance(metadata_xml, bytes):
20722084
mdf = io.BytesIO(metadata_xml)
20732085
else:
20742086
raise TypeError('Expected bytes or str type on metadata_xml, got : {0}'.format(type(metadata_xml)))
2075-
# the first child element has name 'Edmx'
2076-
edmx = etree.parse(mdf)
2087+
2088+
NAMESPACES['edmx'] = None
2089+
NAMESPACES['edm'] = None
2090+
del NAMESPACES['edmx']
2091+
del NAMESPACES['edm']
2092+
2093+
if namespaces is not None:
2094+
NAMESPACES.update(namespaces)
2095+
2096+
xml = etree.parse(mdf)
2097+
edmx = xml.getroot()
2098+
2099+
try:
2100+
dataservices = next((child for child in edmx if etree.QName(child.tag).localname == 'DataServices'))
2101+
except StopIteration:
2102+
raise PyODataParserError('Metadata document is missing the element DataServices')
2103+
2104+
try:
2105+
schema = next((child for child in dataservices if etree.QName(child.tag).localname == 'Schema'))
2106+
except StopIteration:
2107+
raise PyODataParserError('Metadata document is missing the element Schema')
2108+
2109+
if 'edmx' not in NAMESPACES:
2110+
namespace = etree.QName(edmx.tag).namespace
2111+
2112+
if namespace not in EDMX_WHITELIST:
2113+
raise PyODataParserError(f'Unsupported Edmx namespace - {namespace}')
2114+
2115+
NAMESPACES['edmx'] = namespace
2116+
2117+
if 'edm' not in NAMESPACES:
2118+
namespace = etree.QName(schema.tag).namespace
2119+
2120+
if namespace not in EDM_WHITELIST:
2121+
raise PyODataParserError(f'Unsupported Schema namespace - {namespace}')
2122+
2123+
NAMESPACES['edm'] = namespace
20772124

20782125
# aliases - http://docs.oasis-open.org/odata/odata/v4.0/odata-v4.0-part3-csdl.html
2079-
Edmx.update_global_variables_with_alias(Edmx.get_aliases(edmx))
2126+
Edmx.update_global_variables_with_alias(Edmx.get_aliases(xml))
20802127

2081-
edm_schemas = edmx.xpath('/edmx:Edmx/edmx:DataServices/edm:Schema', namespaces=NAMESPACES)
2128+
edm_schemas = xml.xpath('/edmx:Edmx/edmx:DataServices/edm:Schema', namespaces=NAMESPACES)
20822129
schema = Schema.from_etree(edm_schemas)
20832130
return schema
20842131

@@ -2114,7 +2161,7 @@ def update_global_variables_with_alias(aliases):
21142161
SAP_VALUE_HELPER_DIRECTIONS[alias + '.' + suffix] = SAP_VALUE_HELPER_DIRECTIONS[direction_key]
21152162

21162163

2117-
def schema_from_xml(metadata_xml):
2164+
def schema_from_xml(metadata_xml, namespaces=None):
21182165
"""Parses XML data and returns Schema representing OData Metadata"""
21192166

2120-
return Edmx.parse(metadata_xml)
2167+
return Edmx.parse(metadata_xml, namespaces=namespaces)

tests/conftest.py

Lines changed: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -313,38 +313,89 @@ def metadata():
313313
def metadata_builder_factory():
314314
"""Skeleton OData metadata"""
315315

316-
# pylint: disable=line-too-long
317-
318-
class MetadaBuilder(object):
316+
class MetadaBuilder:
319317
"""Helper class for building XML metadata document"""
320318

321-
PROLOGUE = """
322-
<edmx:Edmx xmlns:edmx="http://schemas.microsoft.com/ado/2007/06/edmx" xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" xmlns:sap="http://www.sap.com/Protocols/SAPData" Version="1.0">
323-
<edmx:Reference xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx" Uri="https://example.sap.corp/sap/opu/odata/IWFND/CATALOGSERVICE;v=2/Vocabularies(TechnicalName='%2FIWBEP%2FVOC_COMMON',Version='0001',SAP__Origin='LOCAL')/$value">
324-
<edmx:Include Namespace="com.sap.vocabularies.Common.v1" Alias="Common"/>
325-
</edmx:Reference>
326-
<edmx:DataServices m:DataServiceVersion="2.0">
327-
"""
319+
# pylint: disable=too-many-instance-attributes,line-too-long
320+
def __init__(self):
321+
self.reference_is_enabled = True
322+
self.data_services_is_enabled = True
323+
self.schema_is_enabled = True
328324

329-
EPILOGUE = """
330-
</edmx:DataServices>
331-
</edmx:Edmx>
332-
"""
325+
self.namespaces = {
326+
'edmx': "http://schemas.microsoft.com/ado/2007/06/edmx",
327+
'sap': 'http://www.sap.com/Protocols/SAPData',
328+
'edm': 'http://schemas.microsoft.com/ado/2008/09/edm',
329+
'm': 'http://schemas.microsoft.com/ado/2007/08/dataservices/metadata',
330+
'd': 'http://schemas.microsoft.com/ado/2007/08/dataservices',
331+
}
332+
333+
self.custom_edmx_prologue = None
334+
self.custom_edmx_epilogue = None
335+
336+
self.custom_data_services_prologue = None
337+
self.custom_data_services_epilogue = None
338+
339+
self._reference = '\n<edmx:Reference xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx" Uri="https://example.sap.corp/sap/opu/odata/IWFND/CATALOGSERVICE;v=2/Vocabularies(TechnicalName=\'%2FIWBEP%2FVOC_COMMON\',Version=\'0001\',SAP__Origin=\'LOCAL\')/$value">' + \
340+
'\n<edmx:Include Namespace="com.sap.vocabularies.Common.v1" Alias="Common"/>' + \
341+
'\n</edmx:Reference>'
333342

334-
def __init__(self):
335343
self._schemas = ''
336344

337345
def add_schema(self, namespace, xml_definition):
338346
"""Add schema element"""
339-
340-
self._schemas += '<Schema xmlns:d="http://schemas.microsoft.com/ado/2007/08/dataservices" xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" xmlns="http://schemas.microsoft.com/ado/2008/09/edm" Namespace="{0}" xml:lang="en" sap:schema-version="1">'.format(namespace)
341-
self._schemas += xml_definition
342-
self._schemas += '</Schema>'
347+
self._schemas += f""""\n<Schema xmlns:d="{self.namespaces["d"]}" xmlns:m="{self.namespaces["m"]}" xmlns="{
348+
self.namespaces["edm"]}" Namespace="{namespace}" xml:lang="en" sap:schema-version="1">"""
349+
self._schemas += "\n" + xml_definition
350+
self._schemas += '\n</Schema>'
343351

344352
def serialize(self):
345353
"""Returns full metadata XML document"""
346-
347-
return MetadaBuilder.PROLOGUE + self._schemas + MetadaBuilder.EPILOGUE
354+
result = self._edmx_prologue()
355+
356+
if self.reference_is_enabled:
357+
result += self._reference
358+
359+
if self.data_services_is_enabled:
360+
result += self._data_services_prologue()
361+
362+
if self.schema_is_enabled:
363+
result += self._schemas
364+
365+
if self.data_services_is_enabled:
366+
result += self._data_services_epilogue()
367+
368+
result += self._edmx_epilogue()
369+
370+
return result
371+
372+
def _edmx_prologue(self):
373+
if self.custom_edmx_prologue:
374+
prologue = self.custom_edmx_prologue
375+
else:
376+
prologue = f"""<edmx:Edmx xmlns:edmx="{self.namespaces["edmx"]}" xmlns:m="{self.namespaces["m"]}" xmlns:sap="{self.namespaces["sap"]}" Version="1.0">"""
377+
return prologue
378+
379+
def _edmx_epilogue(self):
380+
if self.custom_edmx_epilogue:
381+
epilogue = self.custom_edmx_epilogue
382+
else:
383+
epilogue = '\n</edmx:Edmx>'
384+
return epilogue
385+
386+
def _data_services_prologue(self):
387+
if self.custom_data_services_prologue:
388+
prologue = self.custom_data_services_prologue
389+
else:
390+
prologue = '\n<edmx:DataServices m:DataServiceVersion="2.0">'
391+
return prologue
392+
393+
def _data_services_epilogue(self):
394+
if self.custom_data_services_epilogue:
395+
prologue = self.custom_data_services_epilogue
396+
else:
397+
prologue = '\n</edmx:DataServices>'
398+
return prologue
348399

349400
return MetadaBuilder
350401

tests/test_model_v2.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66
from pyodata.v2.model import Edmx, Typ, StructTypeProperty, Types, EntityType, EdmStructTypeSerializer,\
77
Association, AssociationSet, EndRole, AssociationSetEndRole
8-
from pyodata.exceptions import PyODataException, PyODataModelError
8+
from pyodata.exceptions import PyODataException, PyODataModelError, PyODataParserError
99

1010

1111
def test_edmx(schema):
@@ -807,3 +807,74 @@ def test_edmx_association_set_end_by_entity_set():
807807

808808
assert association_set.end_by_entity_set(end_from.entity_set_name) == end_from
809809
assert association_set.end_by_entity_set(end_to.entity_set_name) == end_to
810+
811+
812+
def test_missing_data_service(metadata_builder_factory):
813+
"""Test correct handling of missing DataService tag in xml"""
814+
815+
builder = metadata_builder_factory()
816+
builder.data_services_is_enabled = False
817+
xml = builder.serialize()
818+
819+
try:
820+
Edmx.parse(xml)
821+
except PyODataParserError as ex:
822+
assert str(ex) == 'Metadata document is missing the element DataServices'
823+
824+
825+
def test_missing_schema(metadata_builder_factory):
826+
"""Test correct handling of missing Schema tag in xml"""
827+
828+
builder = metadata_builder_factory()
829+
builder.schema_is_enabled = False
830+
xml = builder.serialize()
831+
832+
try:
833+
Edmx.parse(xml)
834+
except PyODataParserError as ex:
835+
assert str(ex) == 'Metadata document is missing the element Schema'
836+
837+
838+
def test_namespace_whitelist(metadata_builder_factory):
839+
"""Test correct handling of whitelisted namespaces"""
840+
841+
builder = metadata_builder_factory()
842+
builder.namespaces['edmx'] = 'http://docs.oasis-open.org/odata/ns/edmx'
843+
builder.namespaces['edm'] = 'http://docs.oasis-open.org/odata/ns/edm'
844+
builder.add_schema('', '')
845+
xml = builder.serialize()
846+
Edmx.parse(xml)
847+
848+
849+
def test_unsupported_edmx_n(metadata_builder_factory):
850+
"""Test correct handling of non-whitelisted Edmx namespaces"""
851+
852+
builder = metadata_builder_factory()
853+
edmx = 'wedonotsupportthisnamespace.com'
854+
builder.namespaces['edmx'] = edmx
855+
builder.add_schema('', '')
856+
xml = builder.serialize()
857+
858+
Edmx.parse(xml, {'edmx': edmx})
859+
860+
try:
861+
Edmx.parse(xml)
862+
except PyODataParserError as ex:
863+
assert str(ex) == f'Unsupported Edmx namespace - {edmx}'
864+
865+
866+
def test_unsupported_schema_n(metadata_builder_factory):
867+
"""Test correct handling of non-whitelisted Schema namespaces"""
868+
869+
builder = metadata_builder_factory()
870+
edm = 'wedonotsupportthisnamespace.com'
871+
builder.namespaces['edm'] = edm
872+
builder.add_schema('', '')
873+
xml = builder.serialize()
874+
875+
Edmx.parse(xml, {'edm': edm})
876+
877+
try:
878+
Edmx.parse(xml)
879+
except PyODataParserError as ex:
880+
assert str(ex) == f'Unsupported Schema namespace - {edm}'

0 commit comments

Comments
 (0)