Skip to content

Commit b5f7af5

Browse files
committed
service, vendor.SAP: add response_hook for pre-handler response inspection
- Add an optional response_hook=None parameter to Service and Client response_hook fires inside _call_handler() before the domain handler runs, covering both execute() and async_execute(). The hook is a stateless Callable[[response], None]; raising from it propagates to the caller and suppresses the domain result. No HTTP networking objects cross the OData API boundary. - Add sap_header_error_hook() to pyodata.vendor.SAP reads the sap-message response header and raises BusinessGatewayError when severity is "error". - Update tests and documentation accordingly
1 parent c40f3f9 commit b5f7af5

9 files changed

Lines changed: 218 additions & 29 deletions

File tree

.pylintrc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ exclude-protected=_asdict,_fields,_replace,_source,_make
336336
[DESIGN]
337337

338338
# Maximum number of arguments for function / method
339-
max-args=7
339+
max-args=8
340340

341341
# Argument names that match this expression will be ignored. Default to name
342342
# with leading underscore
@@ -358,7 +358,7 @@ max-statements=50
358358
max-parents=7
359359

360360
# Maximum number of attributes for a class (see R0902).
361-
max-attributes=7
361+
max-attributes=8
362362

363363
# Minimum number of public methods for a class (see R0903).
364364
min-public-methods=1

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
66

77
## [Unreleased]
88

9+
- service: `response_hook` parameter, enables inspection or rejection of raw responses (e.g. header-encoded SAP domain errors) without leaking HTTP transport objects through the OData API boundary.
10+
- vendor/SAP: `sap_header_error_hook(response)` — a stateless hook that detects SAP domain errors encoded in the `sap-message` response header and raises `BusinessGatewayError` before pyodata's domain handler runs.
911
- service: let FunctionRequests return a list of EntityProxies instead of the raw json, when the `ReturnType` is a Collection. - Emil B.
1012
- model: replace regexp-based ISO datetime parsing with `datetime.fromisoformat` for `Edm.DateTime` and `Edm.DateTimeOffset` - Petr Hanak
1113

docs/usage/advanced.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,32 @@ If you need to work with many Entity Sets the same way or if you just need to pi
105105
106106
count = getattr(northwind.entity_sets, 'Employees').get_entities().count().execute()
107107
print(count)
108+
109+
Inspecting responses with a hook
110+
---------------------------------
111+
112+
Some OData services communicate domain errors via HTTP response headers on otherwise-200
113+
responses. Because pyodata discards headers before returning the domain result, callers
114+
cannot detect these errors through the normal return value.
115+
116+
``Client`` (and ``Service`` directly) accept an optional ``response_hook`` parameter — a
117+
``Callable[[response], None]`` that fires before the domain handler runs, for every request
118+
type (including ``async_execute()``). The hook receives the raw response object. Raising an
119+
exception from the hook propagates to the caller and prevents the domain handler from running.
120+
121+
.. code-block:: python
122+
123+
import pyodata
124+
import requests
125+
126+
SERVICE_URL = 'https://odata.example.com/MyService.svc'
127+
128+
def my_hook(response):
129+
if response.headers.get('x-custom-error'):
130+
raise RuntimeError(f"Service signalled error: {response.headers['x-custom-error']}")
131+
132+
service = pyodata.Client(SERVICE_URL, requests.Session(), response_hook=my_hook)
133+
134+
The hook must be stateless to be safe under concurrent and async use. If you need to
135+
handle SAP-specific header errors, use the ready-made hook in ``pyodata.vendor.SAP``
136+
— see :doc:`vendors`.

docs/usage/vendors.rst

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,35 @@ The following code demonstrates using the helper.
3434
session = SAP.add_btp_token_to_session(requests.Session(), KEY, USER, PASSWORD)
3535
# do something more with session object if necessary (e.g. adding sap-client parameter, or CSRF token)
3636
client = pyodata.Client(SERVICE_URL, session)
37+
38+
Detecting SAP domain errors in response headers
39+
------------------------------------------------
40+
41+
Some SAP OData services signal domain errors via the ``sap-message`` response header on
42+
otherwise-200 responses. pyodata's domain handler never sees these headers, so callers
43+
cannot detect the error through the normal return value.
44+
45+
``pyodata.vendor.SAP`` provides a ready-made stateless hook, ``sap_header_error_hook``,
46+
that reads the ``sap-message`` header, parses it as JSON, and raises ``BusinessGatewayError``
47+
when the ``severity`` field equals ``"error"``. Pass it as the ``response_hook`` argument to
48+
``Client`` (or directly to ``Service``):
49+
50+
.. code-block:: python
51+
52+
import pyodata
53+
from pyodata.vendor.SAP import sap_header_error_hook
54+
import requests
55+
56+
SERVICE_URL = 'https://example.com/sap/opu/odata/sap/ZMyService'
57+
58+
session = requests.Session()
59+
client = pyodata.Client(SERVICE_URL, session, response_hook=sap_header_error_hook)
60+
61+
try:
62+
result = client.entity_sets.Employees.get_entity(1).execute()
63+
except pyodata.vendor.SAP.BusinessGatewayError as ex:
64+
print(f"SAP domain error: {ex}")
65+
66+
The hook fires before pyodata's domain handler, so the exception propagates before any
67+
result object is constructed. It is safe under concurrent and async use because it holds
68+
no instance state.

pyodata/client.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ class Client:
5555

5656
@staticmethod
5757
async def build_async_client(url, connection, odata_version=ODATA_VERSION_2, namespaces=None,
58-
config: pyodata.v2.model.Config = None, metadata: str = None):
58+
config: pyodata.v2.model.Config = None, metadata: str = None,
59+
response_hook=None):
5960
"""Create instance of the OData Client for given URL"""
6061

6162
logger = logging.getLogger('pyodata.client')
@@ -69,11 +70,12 @@ async def build_async_client(url, connection, odata_version=ODATA_VERSION_2, nam
6970
metadata = await _async_fetch_metadata(connection, url, logger)
7071
else:
7172
logger.info('Using static metadata')
72-
return Client._build_service(logger, url, connection, odata_version, namespaces, config, metadata)
73+
return Client._build_service(logger, url, connection, odata_version, namespaces, config, metadata,
74+
response_hook=response_hook)
7375
raise PyODataException(f'No implementation for selected odata version {odata_version}')
7476

7577
def __new__(cls, url, connection, odata_version=ODATA_VERSION_2, namespaces=None,
76-
config: pyodata.v2.model.Config = None, metadata: str = None):
78+
config: pyodata.v2.model.Config = None, metadata: str = None, response_hook=None):
7779
"""Create instance of the OData Client for given URL"""
7880

7981
logger = logging.getLogger('pyodata.client')
@@ -88,12 +90,13 @@ def __new__(cls, url, connection, odata_version=ODATA_VERSION_2, namespaces=None
8890
else:
8991
logger.info('Using static metadata')
9092

91-
return Client._build_service(logger, url, connection, odata_version, namespaces, config, metadata)
93+
return Client._build_service(logger, url, connection, odata_version, namespaces, config, metadata,
94+
response_hook=response_hook)
9295
raise PyODataException(f'No implementation for selected odata version {odata_version}')
9396

9497
@staticmethod
9598
def _build_service(logger, url, connection, odata_version=ODATA_VERSION_2, namespaces=None,
96-
config: pyodata.v2.model.Config = None, metadata: str = None):
99+
config: pyodata.v2.model.Config = None, metadata: str = None, response_hook=None):
97100

98101
if config is not None and namespaces is not None:
99102
raise PyODataException('You cannot pass namespaces and config at the same time')
@@ -111,6 +114,6 @@ def _build_service(logger, url, connection, odata_version=ODATA_VERSION_2, names
111114

112115
# create service instance based on model we have
113116
logger.info('Creating OData Service (version: %d)', odata_version)
114-
service = pyodata.v2.service.Service(url, schema, connection, config=config)
117+
service = pyodata.v2.service.Service(url, schema, connection, config=config, response_hook=response_hook)
115118

116119
return service

pyodata/v2/service.py

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -232,14 +232,15 @@ def __repr__(self):
232232
class ODataHttpRequest:
233233
"""Deferred HTTP Request"""
234234

235-
def __init__(self, url, connection, handler, headers=None):
235+
def __init__(self, url, connection, handler, headers=None, response_hook=None):
236236
self._connection = connection
237237
self._url = url
238238
self._handler = handler
239239
self._headers = headers or dict()
240240
self._logger = logging.getLogger(LOGGER_NAME)
241241
self._customs = {} # string -> string hash
242242
self._next_url = None
243+
self._response_hook = response_hook
243244

244245
@property
245246
def handler(self):
@@ -359,6 +360,9 @@ def _call_handler(self, response):
359360
except UnicodeDecodeError:
360361
self._logger.debug(' body: <cannot be decoded>')
361362

363+
if self._response_hook is not None:
364+
self._response_hook(response)
365+
362366
return self._handler(response)
363367

364368
def custom(self, name, value):
@@ -373,7 +377,7 @@ class EntityGetRequest(ODataHttpRequest):
373377

374378
def __init__(self, handler, entity_key, entity_set_proxy, encode_path=True):
375379
super(EntityGetRequest, self).__init__(entity_set_proxy.service.url, entity_set_proxy.service.connection,
376-
handler)
380+
handler, response_hook=entity_set_proxy.service.response_hook)
377381
self._logger = logging.getLogger(LOGGER_NAME)
378382
self._entity_key = entity_key
379383
self._entity_set_proxy = entity_set_proxy
@@ -465,8 +469,8 @@ class EntityCreateRequest(ODataHttpRequest):
465469
Call execute() to send the create-request to the OData service
466470
and get the newly created entity."""
467471

468-
def __init__(self, url, connection, handler, entity_set, last_segment=None):
469-
super(EntityCreateRequest, self).__init__(url, connection, handler)
472+
def __init__(self, url, connection, handler, entity_set, last_segment=None, response_hook=None):
473+
super(EntityCreateRequest, self).__init__(url, connection, handler, response_hook=response_hook)
470474
self._logger = logging.getLogger(LOGGER_NAME)
471475
self._entity_set = entity_set
472476
self._entity_type = entity_set.entity_type
@@ -552,8 +556,8 @@ def set(self, **kwargs):
552556
class EntityDeleteRequest(ODataHttpRequest):
553557
"""Used for deleting entity (DELETE operations on a single entity)"""
554558

555-
def __init__(self, url, connection, handler, entity_set, entity_key, encode_path=True):
556-
super(EntityDeleteRequest, self).__init__(url, connection, handler)
559+
def __init__(self, url, connection, handler, entity_set, entity_key, encode_path=True, response_hook=None):
560+
super(EntityDeleteRequest, self).__init__(url, connection, handler, response_hook=response_hook)
557561
self._logger = logging.getLogger(LOGGER_NAME)
558562
self._entity_set = entity_set
559563
self._entity_key = entity_key
@@ -585,8 +589,9 @@ class EntityModifyRequest(ODataHttpRequest):
585589
ALLOWED_HTTP_METHODS = ['PATCH', 'PUT', 'MERGE']
586590

587591
# pylint: disable=too-many-arguments
588-
def __init__(self, url, connection, handler, entity_set, entity_key, method="PATCH", encode_path=True):
589-
super(EntityModifyRequest, self).__init__(url, connection, handler)
592+
def __init__(self, url, connection, handler, entity_set, entity_key, method="PATCH", encode_path=True,
593+
response_hook=None):
594+
super(EntityModifyRequest, self).__init__(url, connection, handler, response_hook=response_hook)
590595
self._logger = logging.getLogger(LOGGER_NAME)
591596
self._entity_set = entity_set
592597
self._entity_type = entity_set.entity_type
@@ -650,8 +655,8 @@ class QueryRequest(ODataHttpRequest):
650655

651656
# pylint: disable=too-many-instance-attributes
652657

653-
def __init__(self, url, connection, handler, last_segment):
654-
super(QueryRequest, self).__init__(url, connection, handler)
658+
def __init__(self, url, connection, handler, last_segment, response_hook=None):
659+
super(QueryRequest, self).__init__(url, connection, handler, response_hook=response_hook)
655660

656661
self._logger = logging.getLogger(LOGGER_NAME)
657662
self._count = None
@@ -767,8 +772,10 @@ def get_query_params(self):
767772
class FunctionRequest(QueryRequest):
768773
"""Function import request (Service call)"""
769774

770-
def __init__(self, url, connection, handler, function_import):
771-
super(FunctionRequest, self).__init__(url, connection, handler, function_import.name)
775+
def __init__(self, url, connection, handler, function_import, response_hook=None):
776+
super(FunctionRequest, self).__init__(
777+
url, connection, handler, function_import.name,
778+
response_hook=response_hook)
772779

773780
self._function_import = function_import
774781

@@ -1332,8 +1339,8 @@ def __str__(self):
13321339
class GetEntitySetRequest(QueryRequest):
13331340
"""GET on EntitySet"""
13341341

1335-
def __init__(self, url, connection, handler, last_segment, entity_type, encode_path=True):
1336-
super(GetEntitySetRequest, self).__init__(url, connection, handler, last_segment)
1342+
def __init__(self, url, connection, handler, last_segment, entity_type, encode_path=True, response_hook=None):
1343+
super(GetEntitySetRequest, self).__init__(url, connection, handler, last_segment, response_hook=response_hook)
13371344

13381345
self._entity_type = entity_type
13391346
self._encode_path = encode_path
@@ -1554,7 +1561,7 @@ def get_entities_handler(response):
15541561
entity_set_name = self._alias if self._alias is not None else self._entity_set.name
15551562
return GetEntitySetRequest(self._service.url, self._service.connection, get_entities_handler,
15561563
self._parent_last_segment + entity_set_name, self._entity_set.entity_type,
1557-
encode_path=encode_path)
1564+
encode_path=encode_path, response_hook=self._service.response_hook)
15581565

15591566
def create_entity(self, return_code=HTTP_CODE_CREATED):
15601567
"""Creates a new entity in the given entity-set."""
@@ -1572,7 +1579,7 @@ def create_entity_handler(response):
15721579
return EntityProxy(self._service, self._entity_set, self._entity_set.entity_type, entity_props, etag=etag)
15731580

15741581
return EntityCreateRequest(self._service.url, self._service.connection, create_entity_handler, self._entity_set,
1575-
self.last_segment)
1582+
self.last_segment, response_hook=self._service.response_hook)
15761583

15771584
def update_entity(self, key=None, method=None, encode_path=True, **kwargs):
15781585
"""Updates an existing entity in the given entity-set."""
@@ -1595,7 +1602,8 @@ def update_entity_handler(response):
15951602
method = self._service.config['http']['update_method']
15961603

15971604
return EntityModifyRequest(self._service.url, self._service.connection, update_entity_handler, self._entity_set,
1598-
entity_key, method=method, encode_path=encode_path)
1605+
entity_key, method=method, encode_path=encode_path,
1606+
response_hook=self._service.response_hook)
15991607

16001608
def delete_entity(self, key: EntityKey = None, encode_path=True, **kwargs):
16011609
"""Delete the entity"""
@@ -1614,7 +1622,7 @@ def delete_entity_handler(response):
16141622
entity_key = EntityKey(self._entity_set.entity_type, key, **kwargs)
16151623

16161624
return EntityDeleteRequest(self._service.url, self._service.connection, delete_entity_handler, self._entity_set,
1617-
entity_key, encode_path=encode_path)
1625+
entity_key, encode_path=encode_path, response_hook=self._service.response_hook)
16181626

16191627

16201628
# pylint: disable=too-few-public-methods
@@ -1735,17 +1743,19 @@ def function_import_handler(fimport, response):
17351743
return response_data
17361744

17371745
return FunctionRequest(self._service.url, self._service.connection,
1738-
partial(function_import_handler, fimport), fimport)
1746+
partial(function_import_handler, fimport), fimport,
1747+
response_hook=self._service.response_hook)
17391748

17401749

17411750
class Service:
17421751
"""OData service"""
17431752

1744-
def __init__(self, url, schema, connection, config=None):
1753+
def __init__(self, url, schema, connection, config=None, response_hook=None):
17451754
self._url = url
17461755
self._schema = schema
17471756
self._connection = connection
17481757
self._retain_null = config.retain_null if config else False
1758+
self._response_hook = response_hook
17491759
self._entity_container = EntityContainer(self)
17501760
self._function_container = FunctionContainer(self)
17511761

@@ -1769,6 +1779,12 @@ def connection(self):
17691779

17701780
return self._connection
17711781

1782+
@property
1783+
def response_hook(self):
1784+
"""Optional hook called with the raw response before domain handler runs"""
1785+
1786+
return self._response_hook
1787+
17721788
@property
17731789
def retain_null(self):
17741790
"""Whether to respect null-ed values or to substitute them with type specific default values"""

pyodata/vendor/SAP.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,29 @@ def add_btp_token_to_session(session, key, user, password):
4747
return session
4848

4949

50+
def sap_header_error_hook(response):
51+
"""Response hook that detects SAP domain errors encoded in the sap-message header
52+
on otherwise-200 responses.
53+
54+
Pass this as response_hook to Service() to raise BusinessGatewayError before
55+
pyodata's domain handler runs:
56+
57+
service = Service(url, schema, session, response_hook=sap_header_error_hook)
58+
"""
59+
sap_message = response.headers.get('sap-message')
60+
if sap_message is None:
61+
return
62+
63+
try:
64+
msg = json.loads(sap_message)
65+
except ValueError:
66+
return
67+
68+
severity = msg.get('severity', '')
69+
if severity == 'error':
70+
raise BusinessGatewayError(msg.get('message', 'SAP header error'), response)
71+
72+
5073
class BusinessGatewayError(HttpError):
5174
"""To display the right error message"""
5275

0 commit comments

Comments
 (0)