Skip to content

Commit

Permalink
Merge pull request #3488 from open-formulieren/feature/3328-requests-…
Browse files Browse the repository at this point in the history
…client

Provide generic APIClient implementation taking care of mTLS
  • Loading branch information
sergei-maertens authored Sep 19, 2023
2 parents 5934714 + 753575e commit 486d0e0
Show file tree
Hide file tree
Showing 16 changed files with 859 additions and 20 deletions.
75 changes: 75 additions & 0 deletions src/api_client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# API Client

Implements a generic/base API client as a wrapper around `requests` API.

The client is a general purpose HTTP client, meaning it's not limited to RESTful services but also
suitable for SOAP (for example).

## Usage

The client is a thin wrapper around `requests.Session`, with some guard rails in place:

- specify a base URL and check that absolute URL requests fit within the base URL
- manage resources by closing the session even for one-off requests

There are two ways to instantiate a client.

- from a factory (preferred)
- manually

The factory approach is preferred as it provides the most robust way to honour authentication
configuration such as credentials and mutual TLS parameters.

### From a factory

Factories must implement the `api_client.typing.APIClientFactory` protocol, which provides the
client instance with the base URL and any session-level keyword arguments. This could come from a
Django model instance, or some class taking a YAML configuration file.

```py
from api_client import APIClient

from .factories import my_factory

client = APIClient.configure_from(my_factory)

with client:
# ⚡️ context manager -> uses connection pooling and is recommended!
response1 = client.get("some-relative-path", params={"foo": ["bar"]})
response2 = client.post("other-path", json={...})
```

### Manually

```py
from api_client import APIClient
from requests.auth import

client = APIClient(
"https://example.com/api/v1/",
auth=HTTPBasicAuth("superuser", "letmein"),
verify="/path/to/custom/ca-bundle.pem",
)

with client:
# ⚡️ context manager -> uses connection pooling and is recommended!
response1 = client.get("some-relative-path", params={"foo": ["bar"]})
response2 = client.post("other-path", json={...})
```

## Design constraints

- Must support the `requests.Session` API
- Must be compatible with `zgw_consumers.Service`, `stuf.StUFService` and `soap.SOAPService`
- Should encourage best practices (closing resources after use)
- Should not create problems when used with other libraries, e.g. `requests-oauth2client`

The client is "simply" a subclass of `requests.Session` which allows us to achieve many of the above
constraints.

Eventually we'd like to jump ship to use `httpx` rather than `requests` - it has a similar API, but
it's also `async` / `await` capable. The abstraction of the underlying driver (now requests, later
httpx) should not matter and most importantly, not be leaky.

NOTE: not being leaky here means that you can use the requests API (in the future: httpx) like you
would normally do without this library getting in the way.
4 changes: 4 additions & 0 deletions src/api_client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .client import APIClient
from .exceptions import InvalidURLError

__all__ = ["APIClient", "InvalidURLError"]
120 changes: 120 additions & 0 deletions src/api_client/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""
Implements an API client class as a :class:`requests.Session` subclass.
Some inspiration was taken from https://github.com/guillp/requests_oauth2client/,
notably:
* Implementing the client as a ``Session`` subclass
* Providing a base_url and making this absolute
"""
from contextlib import contextmanager
from typing import Any

from furl import furl
from requests import Session

from .exceptions import InvalidURLError
from .typing import APIClientFactory

sentinel = object()


def is_base_url(url: str | furl) -> bool:
"""
Check if a URL is not a relative path/URL.
A URL is considered a base URL if it has:
* a scheme
* a netloc
Protocol relative URLs like //example.com cannot be properly handled by requests,
as there is no default adapter available.
"""
if not isinstance(url, furl):
url = furl(url)
return bool(url.scheme and url.netloc)


class APIClient(Session):
base_url: str
_request_kwargs: dict[str, Any]
_in_context_manager: bool = False

def __init__(self, base_url: str, request_kwargs: dict[str, Any] | None = None):
# base class does not take any kwargs
super().__init__()
# normalize to dict
request_kwargs = request_kwargs or {}

self.base_url = base_url

# set the attributes that requests.Session supports directly, but only if an
# actual value was provided.
for attr in self.__attrs__:
val = request_kwargs.pop(attr, sentinel)
if val is sentinel:
continue
setattr(self, attr, val)

# store the remainder so we can inject it in the ``request`` method.
self._request_kwargs = request_kwargs

def __enter__(self):
self._in_context_manager = True
return super().__enter__()

def __exit__(self, *args):
self._in_context_manager = False
return super().__exit__(*args)

@classmethod
def configure_from(cls, factory: APIClientFactory):
base_url = factory.get_client_base_url()
session_kwargs = factory.get_client_session_kwargs()
return cls(base_url, session_kwargs)

def request(self, method, url, *args, **kwargs):
for attr, val in self._request_kwargs.items():
kwargs.setdefault(attr, val)
url = self.to_absolute_url(url)
with self._maybe_close_session():
return super().request(method, url, *args, **kwargs)

@contextmanager
def _maybe_close_session(self):
"""
Clean up resources to avoid leaking them.
A requests session uses connection pooling when used in a context manager, and
the __exit__ method will properly clean up this connection pool when the block
exists. However, it's also possible to instantiate and use a client outside a
context block which potentially does not clean up any resources.
We detect these situations and close the session if needed.
"""
_should_close = not self._in_context_manager
try:
yield
finally:
if _should_close:
self.close()

def to_absolute_url(self, maybe_relative_url: str) -> str:
base_furl = furl(self.base_url)
# absolute here should be interpreted as "fully qualified url", with a protocol
# and netloc
is_absolute = is_base_url(maybe_relative_url)
if is_absolute:
# we established the target URL is absolute, so ensure that it's contained
# within the self.base_url domain, otherwise you risk sending credentials
# intended for the base URL to some other domain.
has_same_base = maybe_relative_url.startswith(self.base_url)
if not has_same_base:
raise InvalidURLError(
f"Target URL {maybe_relative_url} has a different base URL than the "
f"client ({self.base_url})."
)
return maybe_relative_url
fully_qualified = base_furl / maybe_relative_url
return str(fully_qualified)
5 changes: 5 additions & 0 deletions src/api_client/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import requests


class InvalidURLError(requests.RequestException):
pass
Empty file.
Loading

0 comments on commit 486d0e0

Please sign in to comment.