Repository: ithaka/apiron

Issue:
Allow config for returning full `requests.Response`
**Is your feature request related to a problem? Please describe.**
In some cases there are headers sent from the service endpoint that needs to be read and set on an outgoing response to a user. The current endpoints defined don't pass the headers returned from the service called.

**Describe the solution you'd like**
My first thought is that there would be a param passed when defining an `Endpoint` to return headers or return full requests similar to the `default_method`, `path` and other params that currently exist.

**Describe alternatives you've considered**
I have been able to extend `Endpoint` and overrode the `format_response` method to return `requests.Response` so that I can then read and set the delivered headers as needed.

**Additional context**
My main use case for needing this is for a service endpoint that generates and delivers a file. The service sets `Content-Disposition`, `Content-Type` and other headers that need to be passed to the outgoing file being delivered.

Discussion:
daneah: Still not a big fan of some of these integration-y tests, but I've extracted some common themes to reduce the boilerplate:

https://github.com/ithaka/apiron/pull/83/files#diff-6fe40adc2096d3f6caba21161a37a7dcR8-R23
NazimAli2017: @daneah how could the tests be less integration-y? Can help rewrite them,
daneah: @NazimAli2017 it's likely not a problem with the tests but with the implementation. Usually these kinds of tests mean the implementation wasn't build in the most testable way. Or it may mean the tests are testing something that just assembles a lot of unit testable code, which means they'll inherently be integration-y in nature. I wouldn't hold this change up for that—requires some more thought 😄 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eb64055..912a92a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ## [Unreleased]
 ### Added
 - Ability to run linting and build docs using `tox`
+- Ability to access raw response object when making a call using `return_raw_response_object=True`.
+  Useful for accessing things like response cookies or headers.
 
 ### Changed
 - Moved implementation to `src/` directory for improved end-to-end testing with packaging.
diff --git a/docs/conf.py b/docs/conf.py
index 1dd6d74..a615b0e 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -182,8 +182,8 @@ texinfo_documents = [
 
 # Example configuration for intersphinx: refer to the Python standard library.
 intersphinx_mapping = {
-    'https://docs.python.org/': None,
-    'requests': ('http://docs.python-requests.org/en/master/', None),
+    'https://docs.python.org/3/': None,
+    'requests': ('https://2.python-requests.org/en/master/', None),
     'urllib3': ('https://urllib3.readthedocs.io/en/latest/', None),
 }
 
diff --git a/src/apiron/client.py b/src/apiron/client.py
index 71b3b82..3c6ea9e 100644
--- a/src/apiron/client.py
+++ b/src/apiron/client.py
@@ -142,6 +142,7 @@ def call(
     timeout_spec=DEFAULT_TIMEOUT,
     logger=None,
     allow_redirects=True,
+    return_raw_response_object=False,
     **kwargs
 ):
     """
@@ -195,6 +196,9 @@ def call(
         (optional)
         Enable/disable GET/OPTIONS/POST/PUT/PATCH/DELETE/HEAD redirection
         (default ``True``)
+    :param bool return_raw_response_object:
+        Whether to return a :class:`requests.Response` object or call :func:`format_response` on it first.
+        (Default ``False``)
     :param ``**kwargs``:
         Arguments to be formatted into the ``endpoint`` argument's ``path`` attribute
     :return:
@@ -252,4 +256,4 @@ def call(
     if encoding:
         response.encoding = encoding
 
-    return endpoint.format_response(response)
+    return response if return_raw_response_object else endpoint.format_response(response)


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index a11d738..1e94577 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -5,6 +5,22 @@ import pytest
 from apiron import client, NoHostsAvailableException
 
 
+@pytest.fixture
+def mock_response():
+    response = mock.Mock()
+    response.history = []
+    return response
+
+
+@pytest.fixture
+def mock_endpoint():
+    endpoint = mock.Mock()
+    endpoint.required_headers = {}
+    endpoint.get_formatted_path.return_value = "/foo/"
+    del endpoint.stub_response
+    return endpoint
+
+
 class TestClient:
     @mock.patch("requests.sessions.Session", autospec=True)
     def test_get_adapted_session(self, mock_session):
@@ -13,35 +29,31 @@ class TestClient:
         assert adapter == adapted_session.get_adapter("http://foo.com")
         assert adapter == adapted_session.get_adapter("https://foo.com")
 
-    def test_get_required_headers(self):
+    def test_get_required_headers(self, mock_endpoint):
         service = mock.Mock()
         service.required_headers = {"one": "two"}
-        endpoint = mock.Mock()
-        endpoint.required_headers = {"foo": "bar"}
+        mock_endpoint.required_headers = {"foo": "bar"}
         expected_headers = {}
         expected_headers.update(service.required_headers)
-        expected_headers.update(endpoint.required_headers)
-        assert expected_headers == client.get_required_headers(service, endpoint)
+        expected_headers.update(mock_endpoint.required_headers)
+        assert expected_headers == client.get_required_headers(service, mock_endpoint)
 
     @mock.patch("apiron.client.requests.Request")
     @mock.patch("apiron.client.get_required_headers")
     def test_build_request_object_passes_arguments_to_request_constructor(
-        self, mock_get_required_headers, mock_request_constructor
+        self, mock_get_required_headers, mock_request_constructor, mock_endpoint
     ):
         session = mock.Mock()
 
         service = mock.Mock()
         service.get_hosts.return_value = ["http://host1.biz"]
 
-        endpoint = mock.Mock()
-        endpoint.default_method = "POST"
-        endpoint.get_formatted_path.return_value = "/foo"
-        endpoint.required_headers = {"header": "value"}
-        endpoint.default_params = {}
-        endpoint.required_params = set()
+        mock_endpoint.default_method = "POST"
+        mock_endpoint.required_headers = {"header": "value"}
+        mock_endpoint.required_params = set()
 
         params = {"baz": "qux"}
-        endpoint.get_merged_params.return_value = params
+        mock_endpoint.get_merged_params.return_value = params
         data = "I am a data"
         json = {"raw": "data"}
         headers = {"Accept": "stuff"}
@@ -51,13 +63,13 @@ class TestClient:
         mock_get_required_headers.return_value = {"header": "value"}
         expected_headers = {}
         expected_headers.update(headers)
-        expected_headers.update(endpoint.required_headers)
+        expected_headers.update(mock_endpoint.required_headers)
 
         with mock.patch.object(session, "prepare_request") as mock_prepare_request:
             client.build_request_object(
                 session,
                 service,
-                endpoint,
+                mock_endpoint,
                 params=params,
                 data=data,
                 json=json,
@@ -68,8 +80,8 @@ class TestClient:
             )
 
             mock_request_constructor.assert_called_once_with(
-                url="http://host1.biz/foo",
-                method=endpoint.default_method,
+                url="http://host1.biz/foo/",
+                method=mock_endpoint.default_method,
                 headers=expected_headers,
                 cookies=cookies,
                 params=params,
@@ -85,16 +97,21 @@ class TestClient:
     @mock.patch("apiron.client.build_request_object")
     @mock.patch("requests.adapters.HTTPAdapter", autospec=True)
     @mock.patch("requests.Session", autospec=True)
-    def test_call(self, MockSession, MockAdapter, mock_build_request_object, mock_get_adapted_session, mock_timeout):
+    def test_call(
+        self,
+        MockSession,
+        MockAdapter,
+        mock_build_request_object,
+        mock_get_adapted_session,
+        mock_timeout,
+        mock_response,
+        mock_endpoint,
+    ):
         service = mock.Mock()
         service.get_hosts.return_value = ["http://host1.biz"]
 
-        endpoint = mock.Mock()
-        endpoint.default_method = "GET"
-        endpoint.get_formatted_path.return_value = "/foo/"
-        endpoint.required_headers = {}
-        endpoint.streaming = True
-        del endpoint.stub_response
+        mock_endpoint.default_method = "GET"
+        mock_endpoint.streaming = True
 
         request = mock.Mock()
         request.url = "http://host1.biz/foo/"
@@ -102,37 +119,35 @@ class TestClient:
 
         mock_logger = mock.Mock()
 
-        mock_response = mock.Mock()
         mock_response.status_code = 200
         mock_response.url = "http://host1.biz/foo/"
-        mock_response.history = []
 
         mock_session = MockSession()
         mock_session.send.return_value = mock_response
         mock_get_adapted_session.return_value = mock_session
 
-        client.call(service, endpoint, timeout_spec=mock_timeout, logger=mock_logger)
+        client.call(service, mock_endpoint, timeout_spec=mock_timeout, logger=mock_logger)
 
         mock_get_adapted_session.assert_called_once_with(MockAdapter())
         mock_session.send.assert_called_once_with(
             request,
             timeout=(mock_timeout.connection_timeout, mock_timeout.read_timeout),
-            stream=endpoint.streaming,
+            stream=mock_endpoint.streaming,
             allow_redirects=True,
         )
 
         mock_logger.info.assert_any_call("GET http://host1.biz/foo/")
         mock_logger.info.assert_any_call("200 http://host1.biz/foo/")
 
-        endpoint.default_method = "POST"
+        mock_endpoint.default_method = "POST"
         request.method = "POST"
 
-        client.call(service, endpoint, session=mock_session, timeout_spec=mock_timeout, logger=mock_logger)
+        client.call(service, mock_endpoint, session=mock_session, timeout_spec=mock_timeout, logger=mock_logger)
 
         mock_session.send.assert_any_call(
             request,
             timeout=(mock_timeout.connection_timeout, mock_timeout.read_timeout),
-            stream=endpoint.streaming,
+            stream=mock_endpoint.streaming,
             allow_redirects=True,
         )
 
@@ -142,57 +157,41 @@ class TestClient:
         request.method = "PUT"
 
         client.call(
-            service, endpoint, method="PUT", session=mock_session, timeout_spec=mock_timeout, logger=mock_logger
+            service, mock_endpoint, method="PUT", session=mock_session, timeout_spec=mock_timeout, logger=mock_logger
         )
 
         mock_session.send.assert_any_call(
             request,
             timeout=(mock_timeout.connection_timeout, mock_timeout.read_timeout),
-            stream=endpoint.streaming,
+            stream=mock_endpoint.streaming,
             allow_redirects=True,
         )
 
     @mock.patch("apiron.client.get_adapted_session")
-    def test_call_with_existing_session(self, mock_get_adapted_session):
+    def test_call_with_existing_session(self, mock_get_adapted_session, mock_response, mock_endpoint):
         service = mock.Mock()
         service.get_hosts.return_value = ["http://host1.biz"]
         service.required_headers = {}
 
-        endpoint = mock.Mock()
-        endpoint.required_headers = {}
-        endpoint.get_formatted_path.return_value = "/foo/"
-        del endpoint.stub_response
-
         mock_logger = mock.Mock()
-        mock_response = mock.Mock()
-        mock_response.history = []
 
         session = mock.Mock()
         session.send.return_value = mock_response
 
-        client.call(service, endpoint, session=session, logger=mock_logger)
+        client.call(service, mock_endpoint, session=session, logger=mock_logger)
 
         assert not mock_get_adapted_session.called
         assert not session.close.called
 
-    def test_call_with_explicit_encoding(self):
+    def test_call_with_explicit_encoding(self, mock_response, mock_endpoint):
         service = mock.Mock()
         service.get_hosts.return_value = ["http://host1.biz"]
         service.required_headers = {}
 
-        endpoint = mock.Mock()
-        endpoint.required_headers = {}
-        endpoint.get_formatted_path.return_value = "/foo/"
-        del endpoint.stub_response
-
-        mock_logger = mock.Mock()
-        mock_response = mock.Mock()
-        mock_response.history = []
-
         session = mock.Mock()
         session.send.return_value = mock_response
 
-        client.call(service, endpoint, session=session, logger=mock_logger, encoding="FAKE-CODEC")
+        client.call(service, mock_endpoint, session=session, logger=mock.Mock(), encoding="FAKE-CODEC")
 
         assert "FAKE-CODEC" == mock_response.encoding
 
@@ -203,18 +202,32 @@ class TestClient:
         with pytest.raises(NoHostsAvailableException):
             client.build_request_object(None, service, None)
 
-    def test_choose_host(self):
+    def test_choose_host_returns_one_of_the_available_hosts(self):
         hosts = ["foo", "bar", "baz"]
         service = mock.Mock()
         service.get_hosts.return_value = hosts
         assert client.choose_host(service) in hosts
 
-    def test_choose_host_raises_exception(self):
+    def test_choose_host_raises_exception_when_no_hosts_available(self):
         service = mock.Mock()
         service.get_hosts.return_value = []
         with pytest.raises(NoHostsAvailableException):
             client.choose_host(service)
 
+    def test_call_when_raw_response_object_requested(self, mock_response, mock_endpoint):
+        service = mock.Mock()
+        service.get_hosts.return_value = ["http://host1.biz"]
+        service.required_headers = {}
+
+        session = mock.Mock()
+        session.send.return_value = mock_response
+
+        response = client.call(
+            service, mock_endpoint, session=session, logger=mock.Mock(), return_raw_response_object=True
+        )
+
+        assert response is mock_response
+
 
 @pytest.mark.parametrize(
     "host,path,url",