Skip to content

Commit

Permalink
Add instance scoped requests session and update docs
Browse files Browse the repository at this point in the history
  • Loading branch information
sanjacob committed Nov 23, 2023
1 parent e900a22 commit ec4eeec
Show file tree
Hide file tree
Showing 6 changed files with 95 additions and 36 deletions.
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Changelog
All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.1.0] - 2023-11-23

### Added
- Support for connection pooling within the same client instance

### Changed
- Member `_session` has been renamed `_cookies` to avoid confusion

## [1.0.0] - 2023-11-23

### Added
- Initial release
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ class MyAPIClient:

## Features

- Instance-scoped `requests.Session()` with connection pooling and cookie preservation
- JSON is king, but XML and raw responses are fine too
- Endpoints can use GET, POST, PUT, PATCH, DELETE
- Route parameters are optional
- Easy integration with your custom API classes
- Declare endpoints under different API versions
- Can define the API URL at runtime if not available before
- Can set a custom CookieJar to pass with all requests
- Pass along any parameters you would usually pass to requests
- Can use a session to make all requests
- Custom JSON status error handling


Expand Down
4 changes: 4 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ The short and sweet way to create an API client
>>> client.get_posts() # route parameters are optional


Tiny API Client is a wrapper for `requests` that enables you to succintly write API clients
without much effort. Calls on each instance of a client class will share a `requests.Session`
with cookie preservation and improved performance due to request pooling.

To get started, see the :ref:`basics` first.

.. toctree::
Expand Down
29 changes: 26 additions & 3 deletions docs/quick.rst
Original file line number Diff line number Diff line change
Expand Up @@ -253,23 +253,27 @@ throw an `APIStatusError`.
Session/Cookies
---------------

- Define a `_session` property and all requests will include this cookie jar
- Define a `_cookies` property and all requests will include this cookie jar

::

from http.cookiejar import CookieJar

@api_client('https://example.org')
class MyAPIClient:
def __init__(self, session: CookieJar | dict):
self._session = session
def __init__(self, cookies: CookieJar | dict):
self._cookies = cookies


.. note::

Please do not use a `@property` for this


.. deprecated:: 1.1.0

self._session (which served the same purpose) is deprecated

- Make a request to a different server

There might come a time when you wish to make a request to a different server within the same session, without implementing your own logic
Expand All @@ -281,3 +285,22 @@ There might come a time when you wish to make a request to a different server wi
return response

>>> client.fetch_external_resource(external_url="https://example.org/api/...")


Reserved Names
--------------

The following are meant to be set by the developer if needed

- `self._cookies`
- `self._url`

.. deprecated:: 1.1.0

self._session


Tiny API Client reserves the use of the following member names, where * is a wildcard.

- `self.__client_*`: For client instance attributes
- `self.__api_*`: For class wide client attributes
54 changes: 29 additions & 25 deletions tests/test_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,14 @@ def mock_requests(mocker, example_note):
mock_response = mocker.Mock()
mock_response.json.return_value = example_note

mocked_requests.request.return_value = mock_response
mocked_requests.Session().request.return_value = mock_response
return mocked_requests


def get_request_fn(mock_requests):
return mock_requests.Session().request


def test_get(mock_requests, example_url, example_note):
@api_client(example_url)
class MyClient:
Expand All @@ -59,7 +63,7 @@ def get_my_endpoint(self, response):

client = MyClient()
r = client.get_my_endpoint()
mock_requests.request.assert_called_with(
get_request_fn(mock_requests).assert_called_with(
'GET', f'{example_url}/my-endpoint', timeout=None, cookies=None
)
assert r == example_note
Expand All @@ -74,7 +78,7 @@ def post_my_endpoint(self, response):

client = MyClient()
r = client.post_my_endpoint()
mock_requests.request.assert_called_with(
get_request_fn(mock_requests).assert_called_with(
'POST', f'{example_url}/my-endpoint', timeout=None, cookies=None
)
assert r == example_note
Expand All @@ -89,7 +93,7 @@ def put_my_endpoint(self, response):

client = MyClient()
r = client.put_my_endpoint()
mock_requests.request.assert_called_with(
get_request_fn(mock_requests).assert_called_with(
'PUT', f'{example_url}/my-endpoint', timeout=None, cookies=None
)
assert r == example_note
Expand All @@ -104,7 +108,7 @@ def patch_my_endpoint(self, response):

client = MyClient()
r = client.patch_my_endpoint()
mock_requests.request.assert_called_with(
get_request_fn(mock_requests).assert_called_with(
'PATCH', f'{example_url}/my-endpoint', timeout=None, cookies=None
)
assert r == example_note
Expand All @@ -119,15 +123,15 @@ def delete_my_endpoint(self, response):

client = MyClient()
r = client.delete_my_endpoint()
mock_requests.request.assert_called_with(
get_request_fn(mock_requests).assert_called_with(
'DELETE', f'{example_url}/my-endpoint', timeout=None, cookies=None
)
assert r == example_note


def test_non_json(mocker, example_url):
mocked_requests = mocker.patch('tiny_api_client.requests')
mocked_requests.request.return_value = 'This is a plaintext message'
mock_requests = mocker.patch('tiny_api_client.requests')
get_request_fn(mock_requests).return_value = 'This is a plaintext message'

@api_client(example_url)
class MyClient:
Expand All @@ -141,15 +145,15 @@ def get_my_endpoint(self, response):


def test_non_json_xml(mocker, example_url):
mocked_requests = mocker.patch('tiny_api_client.requests')
mock_requests = mocker.patch('tiny_api_client.requests')
mock_response = mocker.Mock()
mock_response.text = """
<song>
<title>First</title>
</song>
"""

mocked_requests.request.return_value = mock_response
get_request_fn(mock_requests).return_value = mock_response

@api_client(example_url)
class MyClient:
Expand All @@ -172,12 +176,12 @@ def get_my_endpoint(self, response):

client = MyClient()
client.get_my_endpoint()
mock_requests.request.assert_called_with(
get_request_fn(mock_requests).assert_called_with(
'GET', f'{example_url}/my-endpoint', timeout=None, cookies=None
)

client.get_my_endpoint(optional_id='MY_OPTIONAL_ID')
mock_requests.request.assert_called_with(
get_request_fn(mock_requests).assert_called_with(
'GET', f'{example_url}/my-endpoint/MY_OPTIONAL_ID', timeout=None, cookies=None
)

Expand All @@ -191,7 +195,7 @@ def get_my_endpoint(self, response):

client = MyClient()
client.get_my_endpoint(first_id='1', second_id='22', third_id='333')
mock_requests.request.assert_called_with(
get_request_fn(mock_requests).assert_called_with(
'GET', f'{example_url}/my-endpoint/1/child/22/child/333',
timeout=None, cookies=None
)
Expand Down Expand Up @@ -219,7 +223,7 @@ def get_my_endpoint(self, response):

client = MyClient()
client.get_my_endpoint(my_extra_param='hello world')
mock_requests.request.assert_called_with(
get_request_fn(mock_requests).assert_called_with(
'GET', f'{example_url}/my-endpoint', timeout=None, cookies=None,
my_extra_param='hello world'
)
Expand All @@ -234,7 +238,7 @@ def get_my_endpoint(self, response):

client = MyClient()
client.get_my_endpoint()
mock_requests.request.assert_called_with(
get_request_fn(mock_requests).assert_called_with(
'GET', f'{example_url}/my-endpoint', timeout=None, cookies=None,
my_extra_param='hello world'
)
Expand All @@ -249,18 +253,18 @@ def get_my_endpoint(self, response):

client = MyClient()
r = client.get_my_endpoint()
mock_requests.request.assert_called_with(
get_request_fn(mock_requests).assert_called_with(
'GET', f'{example_url}/my-endpoint', timeout=None, cookies=None
)
assert r == example_note['content']


def test_empty_response_error(mocker, mock_requests, example_url):
mocked_requests = mocker.patch('tiny_api_client.requests')
def test_empty_response_error(mocker, example_url):
mock_requests = mocker.patch('tiny_api_client.requests')
mock_response = mocker.Mock()
mock_response.json.return_value = ""

mocked_requests.request.return_value = mock_response
get_request_fn(mock_requests).return_value = mock_response

@api_client(example_url)
class MyClient:
Expand Down Expand Up @@ -290,15 +294,15 @@ def post_my_endpoint(self, response):

client = MyClient()
client.get_my_endpoint()
mock_requests.request.assert_called_with(
get_request_fn(mock_requests).assert_called_with(
'GET', f'{example_url}/v3/my-endpoint', timeout=None, cookies=None
)
client.post_my_endpoint()
mock_requests.request.assert_called_with(
get_request_fn(mock_requests).assert_called_with(
'POST', f'{example_url}/v2/my-endpoint', timeout=None, cookies=None
)
client.put_my_endpoint()
mock_requests.request.assert_called_with(
get_request_fn(mock_requests).assert_called_with(
'PUT', f'{example_url}/v1/my-endpoint', timeout=None, cookies=None
)

Expand All @@ -312,7 +316,7 @@ def get_my_endpoint(self, response):

client = MyClient()
client.get_my_endpoint()
mock_requests.request.assert_called_with(
get_request_fn(mock_requests).assert_called_with(
'GET', f'{example_url}/my-endpoint', timeout=example_timeout, cookies=None
)

Expand Down Expand Up @@ -362,7 +366,7 @@ def fetch_my_endpoint(self, response):
r = client.fetch_my_endpoint()

assert r == example_note
mock_requests.request.assert_called_once_with(
get_request_fn(mock_requests).assert_called_once_with(
'GET', f'{example_url}/my-endpoint', timeout=None, cookies=None
)

Expand All @@ -382,7 +386,7 @@ def fetch_my_endpoint(self, response):
r = client.fetch_my_endpoint()

assert r == example_note
mock_requests.request.assert_called_once_with(
get_request_fn(mock_requests).assert_called_once_with(
'GET', f'{example_url}/my-endpoint', timeout=None,
cookies=example_session
)
21 changes: 14 additions & 7 deletions tiny_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
__all__ = ['api_client', 'get', 'post', 'put', 'patch', 'delete', 'api_client_method']

_logger = logging.getLogger(__name__)
_logger.addHandler(logging.NullHandler())


class APIClientError(Exception):
Expand Down Expand Up @@ -118,7 +119,7 @@ def request(endpoint: str, *, version: int = 1, use_api: bool = True,
:param string endpoint: Endpoint to make call to, including placeholders
:param int version: API version to which the endpoint belongs
:param bool json: Togggles JSON parsing of response before returning
:param bool json: Toggles JSON parsing of response before returning
:param dict g_kwargs: Any extra keyword argument will be passed to requests
"""

Expand All @@ -137,18 +138,21 @@ def request_wrapper(self, /, *args: APIEndpointP.args,
:param list args: Passed to the function being wrapped
:param dict kwargs: Any kwargs will be passed to requests
"""
if self._url is None:
raise APINoURLError()

if not hasattr(self, '__client_session'):
_logger.info("Creating new requests session")
self.__client_session = requests.Session()

param_endpoint = endpoint.format_map(dict_safe(kwargs))

# Remove parameters meant for endpoint formatting
formatter = string.Formatter()
for x in formatter.parse(endpoint):
kwargs.pop(x[1], None) # type: ignore

if self._url is None:
raise APINoURLError()

url = self._url.format(version=version)

endpoint_format = f"{url}{param_endpoint}"

if not use_api:
Expand All @@ -159,12 +163,15 @@ def request_wrapper(self, /, *args: APIEndpointP.args,
_logger.debug(f"Making request to {endpoint_format}")

cookies = None
if hasattr(self, '_session'):
if hasattr(self, '_cookies'):
cookies = self._cookies
elif hasattr(self, '_session'):
_logger.warning("_session is deprecated. Use _cookies instead.")
cookies = self._session

# This line generates some errors due to kwargs being passed to
# the non-kwarg-ed requests.request method
response = requests.request(
response = self.__client_session.request(
method, endpoint_format, timeout=self.__api_timeout,
cookies=cookies, **kwargs, **g_kwargs) # type: ignore
endpoint_response: Any = response
Expand Down

0 comments on commit ec4eeec

Please sign in to comment.