Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Observer for http requests #154

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
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
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be nice, separate PR, immediately merged.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah, you update copyright, when you touch the code. This is change is OK.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to separate commit to make it easier to cherry pick this change.

author = 'SAP'

# The short X.Y version
Expand Down
39 changes: 39 additions & 0 deletions docs/usage/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 5 additions & 0 deletions pyodata/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Utilities for Python OData client"""

from .request_observer import RequestObserver, RequestObserverLastCall

__all__ = ["RequestObserver", "RequestObserverLastCall"]
34 changes: 34 additions & 0 deletions pyodata/utils/request_observer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""
Interface for request observer, which allows to catch odata request processing details

Author: Michal Nezerka <michal.nezerka@gmail.com>
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
4 changes: 4 additions & 0 deletions pyodata/v2/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()))))
Expand Down Expand Up @@ -1048,6 +1050,8 @@ def association_set(self, set_name, namespace=None):
except KeyError:
pass

raise KeyError('Association set does not exist')

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be nice, separate PR, immediately merged, unrelated to the new observer class.

@property
def association_sets(self):
return list(itertools.chain(*(decl.list_association_sets() for decl in list(self._decls.values()))))
Expand Down
6 changes: 5 additions & 1 deletion pyodata/v2/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions tests/test_service_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'