Skip to content
Open
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
4 changes: 2 additions & 2 deletions .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ exclude-protected=_asdict,_fields,_replace,_source,_make
[DESIGN]

# Maximum number of arguments for function / method
max-args=7
max-args=8

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

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

# Minimum number of public methods for a class (see R0903).
min-public-methods=1
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

- 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.
- 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.
- service: let FunctionRequests return a list of EntityProxies instead of the raw json, when the `ReturnType` is a Collection. - Emil B.

## [1.11.2]
Expand Down
29 changes: 29 additions & 0 deletions docs/usage/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,32 @@ If you need to work with many Entity Sets the same way or if you just need to pi

count = getattr(northwind.entity_sets, 'Employees').get_entities().count().execute()
print(count)

Inspecting responses with a hook
---------------------------------

Some OData services communicate domain errors via HTTP response headers on otherwise-200
responses. Because pyodata discards headers before returning the domain result, callers
cannot detect these errors through the normal return value.

``Client`` (and ``Service`` directly) accept an optional ``response_hook`` parameter — a
``Callable[[response], None]`` that fires before the domain handler runs, for every request
type (including ``async_execute()``). The hook receives the raw response object. Raising an
exception from the hook propagates to the caller and prevents the domain handler from running.

.. code-block:: python

import pyodata
import requests

SERVICE_URL = 'https://odata.example.com/MyService.svc'

def my_hook(response):
if response.headers.get('x-custom-error'):
raise RuntimeError(f"Service signalled error: {response.headers['x-custom-error']}")

service = pyodata.Client(SERVICE_URL, requests.Session(), response_hook=my_hook)

The hook must be stateless to be safe under concurrent and async use. If you need to
handle SAP-specific header errors, use the ready-made hook in ``pyodata.vendor.SAP``
— see :doc:`vendors`.
32 changes: 32 additions & 0 deletions docs/usage/vendors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,35 @@ The following code demonstrates using the helper.
session = SAP.add_btp_token_to_session(requests.Session(), KEY, USER, PASSWORD)
# do something more with session object if necessary (e.g. adding sap-client parameter, or CSRF token)
client = pyodata.Client(SERVICE_URL, session)

Detecting SAP domain errors in response headers
------------------------------------------------

Some SAP OData services signal domain errors via the ``sap-message`` response header on
otherwise-200 responses. pyodata's domain handler never sees these headers, so callers
cannot detect the error through the normal return value.

``pyodata.vendor.SAP`` provides a ready-made stateless hook, ``sap_header_error_hook``,
that reads the ``sap-message`` header, parses it as JSON, and raises ``BusinessGatewayError``
when the ``severity`` field equals ``"error"``. Pass it as the ``response_hook`` argument to
``Client`` (or directly to ``Service``):

.. code-block:: python

import pyodata
from pyodata.vendor.SAP import sap_header_error_hook
import requests

SERVICE_URL = 'https://example.com/sap/opu/odata/sap/ZMyService'

session = requests.Session()
client = pyodata.Client(SERVICE_URL, session, response_hook=sap_header_error_hook)

try:
result = client.entity_sets.Employees.get_entity(1).execute()
except pyodata.vendor.SAP.BusinessGatewayError as ex:
print(f"SAP domain error: {ex}")

The hook fires before pyodata's domain handler, so the exception propagates before any
result object is constructed. It is safe under concurrent and async use because it holds
no instance state.
15 changes: 9 additions & 6 deletions pyodata/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ class Client:

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

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

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

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

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

@staticmethod
def _build_service(logger, url, connection, odata_version=ODATA_VERSION_2, namespaces=None,
config: pyodata.v2.model.Config = None, metadata: str = None):
config: pyodata.v2.model.Config = None, metadata: str = None, response_hook=None):

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

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

return service
56 changes: 36 additions & 20 deletions pyodata/v2/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,14 +232,15 @@ def __repr__(self):
class ODataHttpRequest:
"""Deferred HTTP Request"""

def __init__(self, url, connection, handler, headers=None):
def __init__(self, url, connection, handler, headers=None, response_hook=None):
self._connection = connection
self._url = url
self._handler = handler
self._headers = headers or dict()
self._logger = logging.getLogger(LOGGER_NAME)
self._customs = {} # string -> string hash
self._next_url = None
self._response_hook = response_hook

@property
def handler(self):
Expand Down Expand Up @@ -359,6 +360,9 @@ def _call_handler(self, response):
except UnicodeDecodeError:
self._logger.debug(' body: <cannot be decoded>')

if self._response_hook is not None:
self._response_hook(response)

return self._handler(response)

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

def __init__(self, handler, entity_key, entity_set_proxy, encode_path=True):
super(EntityGetRequest, self).__init__(entity_set_proxy.service.url, entity_set_proxy.service.connection,
handler)
handler, response_hook=entity_set_proxy.service.response_hook)
self._logger = logging.getLogger(LOGGER_NAME)
self._entity_key = entity_key
self._entity_set_proxy = entity_set_proxy
Expand Down Expand Up @@ -465,8 +469,8 @@ class EntityCreateRequest(ODataHttpRequest):
Call execute() to send the create-request to the OData service
and get the newly created entity."""

def __init__(self, url, connection, handler, entity_set, last_segment=None):
super(EntityCreateRequest, self).__init__(url, connection, handler)
def __init__(self, url, connection, handler, entity_set, last_segment=None, response_hook=None):
super(EntityCreateRequest, self).__init__(url, connection, handler, response_hook=response_hook)
self._logger = logging.getLogger(LOGGER_NAME)
self._entity_set = entity_set
self._entity_type = entity_set.entity_type
Expand Down Expand Up @@ -552,8 +556,8 @@ def set(self, **kwargs):
class EntityDeleteRequest(ODataHttpRequest):
"""Used for deleting entity (DELETE operations on a single entity)"""

def __init__(self, url, connection, handler, entity_set, entity_key, encode_path=True):
super(EntityDeleteRequest, self).__init__(url, connection, handler)
def __init__(self, url, connection, handler, entity_set, entity_key, encode_path=True, response_hook=None):
super(EntityDeleteRequest, self).__init__(url, connection, handler, response_hook=response_hook)
self._logger = logging.getLogger(LOGGER_NAME)
self._entity_set = entity_set
self._entity_key = entity_key
Expand Down Expand Up @@ -585,8 +589,9 @@ class EntityModifyRequest(ODataHttpRequest):
ALLOWED_HTTP_METHODS = ['PATCH', 'PUT', 'MERGE']

# pylint: disable=too-many-arguments
def __init__(self, url, connection, handler, entity_set, entity_key, method="PATCH", encode_path=True):
super(EntityModifyRequest, self).__init__(url, connection, handler)
def __init__(self, url, connection, handler, entity_set, entity_key, method="PATCH", encode_path=True,
response_hook=None):
super(EntityModifyRequest, self).__init__(url, connection, handler, response_hook=response_hook)
self._logger = logging.getLogger(LOGGER_NAME)
self._entity_set = entity_set
self._entity_type = entity_set.entity_type
Expand Down Expand Up @@ -650,8 +655,8 @@ class QueryRequest(ODataHttpRequest):

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

def __init__(self, url, connection, handler, last_segment):
super(QueryRequest, self).__init__(url, connection, handler)
def __init__(self, url, connection, handler, last_segment, response_hook=None):
super(QueryRequest, self).__init__(url, connection, handler, response_hook=response_hook)

self._logger = logging.getLogger(LOGGER_NAME)
self._count = None
Expand Down Expand Up @@ -767,8 +772,10 @@ def get_query_params(self):
class FunctionRequest(QueryRequest):
"""Function import request (Service call)"""

def __init__(self, url, connection, handler, function_import):
super(FunctionRequest, self).__init__(url, connection, handler, function_import.name)
def __init__(self, url, connection, handler, function_import, response_hook=None):
super(FunctionRequest, self).__init__(
url, connection, handler, function_import.name,
response_hook=response_hook)

self._function_import = function_import

Expand Down Expand Up @@ -1332,8 +1339,8 @@ def __str__(self):
class GetEntitySetRequest(QueryRequest):
"""GET on EntitySet"""

def __init__(self, url, connection, handler, last_segment, entity_type, encode_path=True):
super(GetEntitySetRequest, self).__init__(url, connection, handler, last_segment)
def __init__(self, url, connection, handler, last_segment, entity_type, encode_path=True, response_hook=None):
super(GetEntitySetRequest, self).__init__(url, connection, handler, last_segment, response_hook=response_hook)

self._entity_type = entity_type
self._encode_path = encode_path
Expand Down Expand Up @@ -1554,7 +1561,7 @@ def get_entities_handler(response):
entity_set_name = self._alias if self._alias is not None else self._entity_set.name
return GetEntitySetRequest(self._service.url, self._service.connection, get_entities_handler,
self._parent_last_segment + entity_set_name, self._entity_set.entity_type,
encode_path=encode_path)
encode_path=encode_path, response_hook=self._service.response_hook)

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

return EntityCreateRequest(self._service.url, self._service.connection, create_entity_handler, self._entity_set,
self.last_segment)
self.last_segment, response_hook=self._service.response_hook)

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

return EntityModifyRequest(self._service.url, self._service.connection, update_entity_handler, self._entity_set,
entity_key, method=method, encode_path=encode_path)
entity_key, method=method, encode_path=encode_path,
response_hook=self._service.response_hook)

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

return EntityDeleteRequest(self._service.url, self._service.connection, delete_entity_handler, self._entity_set,
entity_key, encode_path=encode_path)
entity_key, encode_path=encode_path, response_hook=self._service.response_hook)


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

return FunctionRequest(self._service.url, self._service.connection,
partial(function_import_handler, fimport), fimport)
partial(function_import_handler, fimport), fimport,
response_hook=self._service.response_hook)


class Service:
"""OData service"""

def __init__(self, url, schema, connection, config=None):
def __init__(self, url, schema, connection, config=None, response_hook=None):
self._url = url
self._schema = schema
self._connection = connection
self._retain_null = config.retain_null if config else False
self._response_hook = response_hook
self._entity_container = EntityContainer(self)
self._function_container = FunctionContainer(self)

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

return self._connection

@property
def response_hook(self):
"""Optional hook called with the raw response before domain handler runs"""

return self._response_hook

@property
def retain_null(self):
"""Whether to respect null-ed values or to substitute them with type specific default values"""
Expand Down
23 changes: 23 additions & 0 deletions pyodata/vendor/SAP.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,29 @@ def add_btp_token_to_session(session, key, user, password):
return session


def sap_header_error_hook(response):
"""Response hook that detects SAP domain errors encoded in the sap-message header
on otherwise-200 responses.

Pass this as response_hook to Service() to raise BusinessGatewayError before
pyodata's domain handler runs:

service = Service(url, schema, session, response_hook=sap_header_error_hook)
"""
sap_message = response.headers.get('sap-message')
if sap_message is None:
return

try:
msg = json.loads(sap_message)
except ValueError:
return

severity = msg.get('severity', '')
if severity == 'error':
raise BusinessGatewayError(msg.get('message', 'SAP header error'), response)


class BusinessGatewayError(HttpError):
"""To display the right error message"""

Expand Down
Loading
Loading