Skip to content

Commit

Permalink
feat: initial support for Easyverein API version 2.0, mainly for the …
Browse files Browse the repository at this point in the history
…new token and token expiration scheme.
  • Loading branch information
waza-ari committed Aug 20, 2024
1 parent c3b3c72 commit 9d8cfbc
Show file tree
Hide file tree
Showing 9 changed files with 130 additions and 7 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ When saying CRUD, it means the library supports various methods to **C**reate, *
* `invoice`: CRUD, Soft-Delete, plus some convenience methods
* `invoice-item`: CRUD
* `member`: CRUD, Soft-Delete
* `member-groups`: CRUD, Soft-Delete
* `member/<id>/custom-fields`: CRUD, plus some convenience methods
* `member/<id>/member-groups`: CRUD, plus some convenience methods
* `wastebasket` (its the official name used by the EasyVerein API to reference soft-deleted objects)

In addition to that, the library supports nested queries using the query syntax, included nested model validation.
Expand Down
4 changes: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Changelog

The changelog is maintained in the Releases page of the [GitHub repository](https://github.com/waza-ari/python-easyverein/releases)
and is not replicated here. Please refer to the releases for details on what has changed in each version.
26 changes: 21 additions & 5 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ the hood, so you get auto-completion and a guaranteed interface for these models
The [model reference](models/base.md) contains important information about the model attributes and their potential values. Most of
them are not easily available from the EasyVerein documentation, so they might be of use to you.

## API Versions

Currently, the API versions v1.7 and v2.0 are supported. Please note that support for v2.0 is experimental and might
not work as expected. The library defaults to v1.7, but you can change the version by setting the `api_version` attribute
of the `EasyvereinAPI` object.

Version 2.0 introduces a change to authentication, it does not allow the ephemeral API keys anymore. Instead,
a new type of token is used, which expires after 30 days. Please check the usage section on details how to handle
token expiration.

## State of the API

This client was written against and tested against the Hexa v1.7 API version of EasyVerein. It may or may not work
Expand Down Expand Up @@ -92,14 +102,20 @@ EV_API_KEY=<your-api-key>

## Contributing

The client is written in pure Python, using `mkdocs` with `mkdocstrings` for documentation. Any changes or
The client is written in pure Python, using `mkdocs` with `mkdocstrings` for documentation. Any changes or
pull requests are more than welcome, but please adhere to the code style:

- Use `black` for code formatting
- Use `isort` based import sorting
- Use `ruff` based code linting
- Use `ruff` based code linting, formatting and styling
- Use `mypy` for static type checking

A pre-commit hook configuration is supplied as part of the project. You can run them prior to your commit using:

A pre-commit hook configuration is supplied as part of the project.
```bash
pre-commit

# Or run them for the entire project
pre-commit run --all-files
```

Please make sure that any additions are properly tested. PRs won't get accepted if they don't have test cases to
cover them.
Expand Down
48 changes: 48 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ You can optionally specify your own Python `logger`, see the logging section bel
Once you have the client object, all supported attributes are composed into the client as instance attributes

```python
from easyverein import EasyvereinAPI

c = EasyvereinAPI(api_key="your_key")

# Get invoices
Expand All @@ -37,6 +39,52 @@ c.member.get()

The available endpoints are documented in the API Reference section of this documentation.

## Handling Token Refresh

Starting version v2.0, the EasyVerein API enforces token expiration. The token you get from
the API configuration page is only valid for 30 days and must be refreshed afterwards. This library is not
responsible for storing the token, but you can pass a callback function that will be called if a token refresh
is needed. Optionally, you can instruct the library to automatically refresh the token for you and pass it to the
callback function.

!!! info "Version 2.0 only"
Please not that the new token type is only supported in API version 2.0. If you're using API version 1.7, you
do not need to configure token refresh, the library will raise an exception if you try.

There are two ways this can be done, one synchronous and one asynchronous option. When using asynchronously,
the callback will be called without any arguments, just a a trigger for you to refresh and store the token somewhere
else, for example using an async worker. It does not extend the runtime of the initial request.

```python
from easyverein import BearerToken, EasyvereinAPI

def handle_token_refresh() -> None:
# This merely serves as a trigger for you to refresh and store the token
return None

c = EasyvereinAPI(api_key="your_key", api_version="v2.0", token_refresh_callback=handle_token_refresh)
```

The more convenient way is to use the automatic token refresh. This will automatically refresh the token for you and
pass it to the callback for you to store. It does so after the initial request is completed (as this is needed to get
the feedback from the API in the first place) but before the result is returned. It therefore extends the runtime of
the request by a small amount, which may or may not be acceptable for your use case.

```python
from easyverein import BearerToken, EasyvereinAPI

def handle_token_refresh(new_token: BearerToken) -> None:
print(f"New token: {new_token.Bearer}")
return None

c = EasyvereinAPI(
api_key="your_key",
api_version="v2.0",
token_refresh_callback=handle_token_refresh,
auto_refresh_token=True
)
```

## Pydantic Models

All interactions with the API are handled by using Pydantic (v2) models. They do the heavy lifting in terms of
Expand Down
1 change: 1 addition & 0 deletions easyverein/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
EasyvereinAPINotFoundException,
EasyvereinAPITooManyRetriesException,
)
from .core.responses import BearerToken
42 changes: 42 additions & 0 deletions easyverein/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@
"""

import logging
from typing import Callable

from .core.client import EasyvereinClient
from .core.responses import BearerToken
from .modules.contact_details import ContactDetailsMixin
from .modules.custom_field import CustomFieldMixin
from .modules.invoice import InvoiceMixin
from .modules.invoice_item import InvoiceItemMixin
from .modules.member import MemberMixin
from .modules.member_group import MemberGroupMixin
from .modules.mixins.helper import parse_models

SUPPORTED_API_VERSIONS = ["v1.6", "v1.7", "v2.0"]


class EasyvereinAPI:
Expand All @@ -21,6 +26,8 @@ def __init__(
base_url: str = "https://hexa.easyverein.com/api/",
logger: logging.Logger | None = None,
auto_retry=False,
token_refresh_callback: Callable[[BearerToken], None] | Callable[[], None] | None = None,
auto_refresh_token: bool = False,
):
"""
Constructor setting API key and logger. Test
Expand All @@ -33,6 +40,20 @@ def __init__(
else:
self.logger = logging.getLogger("easyverein")

# Check parameters
if api_version not in SUPPORTED_API_VERSIONS:
self.logger.error(
f"API version {api_version} is not supported. Supported versions are {SUPPORTED_API_VERSIONS}"
)
raise ValueError(
f"API version {api_version} is not supported. Supported versions are {SUPPORTED_API_VERSIONS}"
)

if (auto_refresh_token or token_refresh_callback) and api_version != "v2.0":
raise ValueError("Token refresh is only supported in API version v2.0")

self.token_refresh_callback = token_refresh_callback
self.auto_refresh_token = auto_refresh_token
self.c = EasyvereinClient(api_key, api_version, base_url, self.logger, self, auto_retry)

# Add methods
Expand All @@ -42,3 +63,24 @@ def __init__(
self.invoice_item = InvoiceItemMixin(self.c, self.logger)
self.member = MemberMixin(self.c, self.logger)
self.member_group = MemberGroupMixin(self.c, self.logger)

def handle_token_refresh(self):
"""
This method is called by the client if a token refresh is required according to the API response.
"""

if self.token_refresh_callback:
self.logger.info("Notifying token refresh callback to refresh token")
self.token_refresh_callback(self.refresh_token() if self.auto_refresh_token else None)

def refresh_token(self) -> BearerToken:
"""
Refreshes the bearer token (only valid for API v2.0)
"""

if not self.c.api_version == "v2.0":
self.logger.error("Refresh token is only available for API v2.0")
raise ValueError("Refresh token is only available for API v2.0")

response = self.c.fetch_one(self.c.get_url("/refresh-token"))
return parse_models(response.result, BearerToken)
8 changes: 6 additions & 2 deletions easyverein/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,7 @@ def _do_request( # noqa: PLR0913
files: dict[str, BufferedReader] | None = None,
) -> tuple[int, dict[str, Any] | requests.Response | None]:
"""
Helper method that performs an actual call against the API,
fetching the most common errors
Helper method that performs an actual call against the API, catching the most common errors
"""
self.logger.debug("Performing %s request to %s", method, url)
if data:
Expand Down Expand Up @@ -141,6 +140,11 @@ def _do_request( # noqa: PLR0913
retry_after=retry_after,
)

# If API version is v2.0, check if token refresh is required
if self.api_version == "v2.0" and res.headers.get("tokenRefreshNeeded", "false") == "True":
self.logger.info("Token refresh required")
self.api_instance.handle_token_refresh()

if res.status_code == 404:
self.logger.warning("Request returned status code 404, resource not found")
raise EasyvereinAPINotFoundException("Requested resource not found")
Expand Down
5 changes: 5 additions & 0 deletions easyverein/core/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,10 @@ class ResponseSchema(BaseModel):
count: int | None = None
response_code: int
response: Response | None = None
token_refresh_required: bool = False

model_config = ConfigDict(arbitrary_types_allowed=True)


class BearerToken(BaseModel):
Bearer: str
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ nav:
- "Member Groups": models/member_group.md
- "Member Group Associations": models/member_member_group.md
- "Member Custom Field Associations": models/member_custom_field.md
- "Changelog": changelog.md

watch:
- docs
Expand Down

0 comments on commit 9d8cfbc

Please sign in to comment.