diff --git a/docs/conf.py b/docs/conf.py index d7ea7dec..4d6c70ad 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ # -- Project information ----------------------------------------------------- project = 'PyOData' -copyright = '2019 SAP SE or an SAP affiliate company' +copyright = '2021 SAP SE or an SAP affiliate company' author = 'SAP' # The short X.Y version diff --git a/docs/usage/advanced.rst b/docs/usage/advanced.rst index 9348d839..5fd308d5 100644 --- a/docs/usage/advanced.rst +++ b/docs/usage/advanced.rst @@ -95,3 +95,42 @@ it is enough to set log level to the desired value. logging.basicConfig() root_logger = logging.getLogger() root_logger.setLevel(logging.DEBUG) + +Observing HTTP traffic +---------------------- + +There are cases where you need access to the transport protocol (http). For +example: you need to read value of specific http header. Pyodata provides +simple mechanism to observe all http requests and access low level properties +from underlying network library (e.g. **python requests**). + +You can use basic predefined observer class +``pyodata.utils.RequestObserverLastCall`` to catch last response headers: + +.. code-block:: python + + from pyodata.utils import RequestObserverLastCall + + last = RequestObserverLastCall() + northwind.entity_sets.Employees.get_entity(1).execute(last) + print(last.response.headers) + +You can also write your own observer to cover more specific cases. This is an example of +custom observer which stores status code of the last response. + +.. code-block:: python + + from pyodata.utils import RequestObserver + + class CatchStatusCode(RequestObserver): + + def __init__(self): + self.status_code = None + + def http_response(self, response, request): + self.status_code = response.status_code + + last_status = RequestObserverLastCall() + + northwind.entity_sets.Employees.get_entity(1).execute(last_status) + print(last_status.status_code) diff --git a/pyodata/utils/__init__.py b/pyodata/utils/__init__.py new file mode 100644 index 00000000..ba5d131e --- /dev/null +++ b/pyodata/utils/__init__.py @@ -0,0 +1,5 @@ +"""Utilities for Python OData client""" + +from .request_observer import RequestObserver, RequestObserverLastCall + +__all__ = ["RequestObserver", "RequestObserverLastCall"] diff --git a/pyodata/utils/request_observer.py b/pyodata/utils/request_observer.py new file mode 100644 index 00000000..bf7767fd --- /dev/null +++ b/pyodata/utils/request_observer.py @@ -0,0 +1,34 @@ +""" +Interface for request observer, which allows to catch odata request processing details + +Author: Michal Nezerka +Date: 2021-05-14 +""" + +from abc import ABC, abstractmethod + + +class RequestObserver(ABC): + """ + The RequestObserver interface declares methods for observing odata request processing. + """ + + @abstractmethod + def http_response(self, response, request) -> None: + """ + Get http response together with related http request object. + """ + + +class RequestObserverLastCall(RequestObserver): + """ + The implementation of RequestObserver that stored request and response of the last call + """ + + def __init__(self): + self.response = None + self.request = None + + def http_response(self, response, request): + self.response = response + self.request = request diff --git a/pyodata/v2/model.py b/pyodata/v2/model.py index 67cc1ad3..a446ca2b 100644 --- a/pyodata/v2/model.py +++ b/pyodata/v2/model.py @@ -1018,6 +1018,8 @@ def association(self, association_name, namespace=None): except KeyError: pass + raise KeyError('Association does not exist') + @property def associations(self): return list(itertools.chain(*(decl.list_associations() for decl in list(self._decls.values())))) @@ -1048,6 +1050,8 @@ def association_set(self, set_name, namespace=None): except KeyError: pass + raise KeyError('Association set does not exist') + @property def association_sets(self): return list(itertools.chain(*(decl.list_association_sets() for decl in list(self._decls.values())))) diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index 05e1a86d..2a86c7fb 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -22,6 +22,7 @@ from urllib2 import quote from pyodata.exceptions import HttpError, PyODataException, ExpressionError, ProgramError +from pyodata.utils import RequestObserver from . import model LOGGER_NAME = 'pyodata.service' @@ -296,7 +297,7 @@ def add_headers(self, value): self._headers.update(value) - def execute(self): + def execute(self, observer: RequestObserver = None): """Fetches HTTP response and returns processed result Sends the query-request to the OData service, returning a client-side Enumerable for @@ -320,6 +321,9 @@ def execute(self): response = self._connection.request( self.get_method(), url, headers=headers, params=params, data=body) + if observer: + observer.http_response(response, response.request) + self._logger.debug('Received response') self._logger.debug(' url: %s', response.url) self._logger.debug(' headers: %s', response.headers) diff --git a/tests/test_service_v2.py b/tests/test_service_v2.py index 3d857cb6..79792e68 100644 --- a/tests/test_service_v2.py +++ b/tests/test_service_v2.py @@ -6,6 +6,7 @@ import pytest from unittest.mock import patch +from pyodata.utils import RequestObserverLastCall import pyodata.v2.model import pyodata.v2.service from pyodata.exceptions import PyODataException, HttpError, ExpressionError, ProgramError @@ -2372,3 +2373,33 @@ def test_custom_with_get_entity(service): entity = service.entity_sets.MasterEntities.get_entity('12345').custom("foo", "bar").execute() assert entity.Key == '12345' + + +@responses.activate +def test_request_observer(service): + """Test use of request observer""" + + responses.add( + responses.GET, + f"{service.url}/MasterEntities('12345')", + headers={'h1-key': 'h1-val', 'h2-key': 'h2-val'}, + json={'d': {'Key': '12345'}}, + status=200) + + last = RequestObserverLastCall() + entity = service.entity_sets.MasterEntities.get_entity('12345').execute(last) + + assert last.request is not None + + assert len(last.request.headers) > 0 + assert 'Accept' in last.request.headers + + assert last.response is not None + assert last.response.status_code == 200 + assert len(last.response.headers) == 3 + assert 'Content-type' in last.response.headers + assert 'h1-key' in last.response.headers + assert 'h2-key' in last.response.headers + assert last.response.headers['Content-type'] == 'application/json' + assert last.response.headers['h1-key'] == 'h1-val' + assert last.response.headers['h2-key'] == 'h2-val'