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

Initial version of the OpenKlant2 client #1381

Merged
merged 3 commits into from
Sep 12, 2024
Merged
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
10 changes: 6 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
test-type: [main, elastic]
test-type: [main, elastic, openklant]

services:
postgres:
Expand Down Expand Up @@ -70,6 +70,8 @@ jobs:
--exclude-tag=elastic
elif [ "${{ matrix.test-type }}" = "elastic" ]; then
coverage run -p src/manage.py test src --tag=elastic --exclude-tag=e2e
elif [ "${{ matrix.test-type }}" = "openklant" ]; then
coverage run -m pytest --block-network --record-mode=none -vvv src/openklant2
else
echo "Error: Unknown test type '${{ matrix.test-type }}'"
exit 1
Expand Down Expand Up @@ -98,7 +100,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: '3.11'
- name: Install coverage.py
# We only need coverage, but we need to match the version that was used
# to generate the coverage files. Grab it from the dependencies.
Expand All @@ -110,6 +112,7 @@ jobs:
run: |
mv coverages/main/.coverage* .
mv coverages/elastic/.coverage* .
mv coverages/openklant/.coverage* .
coverage combine
- name: Publish coverage report
uses: codecov/codecov-action@v3
Expand Down Expand Up @@ -169,8 +172,7 @@ jobs:
uses: actions/cache@v3
with:
path: /home/runner/.cache/ms-playwright
key:
${{ runner.os }}-${{ matrix.browser }}-playwright-${{ hashFiles('requirements/ci.txt') }}
key: ${{ runner.os }}-${{ matrix.browser }}-playwright-${{ hashFiles('requirements/ci.txt') }}

- name: Install playwright deps
run: playwright install --with-deps ${{ matrix.browser }}
Expand Down
15 changes: 15 additions & 0 deletions requirements/ci.txt
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,7 @@ idna==3.7
# -r requirements/base.txt
# email-validator
# requests
# yarl
imagesize==1.4.1
# via sphinx
inflection==0.5.1
Expand Down Expand Up @@ -751,6 +752,8 @@ mozilla-django-oidc-db==0.14.1
# via
# -c requirements/base.txt
# -r requirements/base.txt
multidict==6.0.5
# via yarl
mypy-extensions==1.0.0
# via black
notifications-api-common==0.2.2
Expand Down Expand Up @@ -890,6 +893,10 @@ pyrsistent==0.18.0
# -r requirements/base.txt
# jsonschema
pytest==8.1.1
# via
# -r requirements/test-tools.in
# pytest-recording
pytest-recording==0.13.2
# via -r requirements/test-tools.in
python-crontab==3.0.0
# via
Expand Down Expand Up @@ -933,6 +940,7 @@ pyyaml==6.0
# drf-spectacular
# gemma-zds-client
# tablib
# vcrpy
# zgw-consumers-oas
qrcode==6.1
# via
Expand Down Expand Up @@ -1088,6 +1096,10 @@ uwsgi==2.0.23
# via
# -c requirements/base.txt
# -r requirements/base.txt
vcrpy==6.0.1
# via
# -r requirements/test-tools.in
# pytest-recording
vine==5.1.0
# via
# -c requirements/base.txt
Expand Down Expand Up @@ -1128,6 +1140,7 @@ wrapt==1.14.1
# -r requirements/base.txt
# astroid
# elastic-apm
# vcrpy
xlrd==2.0.1
# via
# -c requirements/base.txt
Expand All @@ -1147,6 +1160,8 @@ xsdata==23.8
# via
# -c requirements/base.txt
# -r requirements/base.txt
yarl==1.9.8
# via vcrpy
zgw-consumers==0.35.1
# via
# -c requirements/base.txt
Expand Down
4 changes: 4 additions & 0 deletions requirements/dev.in
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ django-debug-toolbar
# performance testing / profiling
locust
django-silk

# VCR
pytest-recording
vcrpy
25 changes: 25 additions & 0 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,7 @@ idna==3.7
# -r requirements/ci.txt
# email-validator
# requests
# yarl
imagesize==1.4.1
# via
# -c requirements/ci.txt
Expand Down Expand Up @@ -855,6 +856,11 @@ mozilla-django-oidc-db==0.14.1
# -r requirements/ci.txt
msgpack==1.0.7
# via locust
multidict==6.0.5
# via
# -c requirements/ci.txt
# -r requirements/ci.txt
# yarl
mypy-extensions==1.0.0
# via
# -c requirements/ci.txt
Expand Down Expand Up @@ -1032,6 +1038,12 @@ pytest==8.1.1
# via
# -c requirements/ci.txt
# -r requirements/ci.txt
# pytest-recording
pytest-recording==0.13.2
# via
# -c requirements/ci.txt
# -r requirements/ci.txt
# -r requirements/dev.in
python-crontab==3.0.0
# via
# -c requirements/ci.txt
Expand Down Expand Up @@ -1075,6 +1087,7 @@ pyyaml==6.0
# drf-spectacular
# gemma-zds-client
# tablib
# vcrpy
# zgw-consumers-oas
pyzmq==25.1.2
# via locust
Expand Down Expand Up @@ -1286,6 +1299,12 @@ uwsgi==2.0.23
# via
# -c requirements/ci.txt
# -r requirements/ci.txt
vcrpy==6.0.1
# via
# -c requirements/ci.txt
# -r requirements/ci.txt
# -r requirements/dev.in
# pytest-recording
vine==5.1.0
# via
# -c requirements/ci.txt
Expand Down Expand Up @@ -1341,6 +1360,7 @@ wrapt==1.14.1
# -r requirements/ci.txt
# astroid
# elastic-apm
# vcrpy
xlrd==2.0.1
# via
# -c requirements/ci.txt
Expand All @@ -1360,6 +1380,11 @@ xsdata==23.8
# via
# -c requirements/ci.txt
# -r requirements/ci.txt
yarl==1.9.8
# via
# -c requirements/ci.txt
# -r requirements/ci.txt
# vcrpy
zgw-consumers==0.35.1
# via
# -c requirements/ci.txt
Expand Down
4 changes: 4 additions & 0 deletions requirements/test-tools.in
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ pyopenssl

# Debug and Docs
django-extensions

# VCR
pytest-recording
vcrpy
27 changes: 27 additions & 0 deletions src/openklant2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Open Klant 2 API Client

This Python package provides a client for interacting with Open Klant 2 services. It simplifies the process of making requests to the API and handling responses.

## Usage

```python
from openklant2 import OpenKlant2Client

client = OpenKlant2Client(api_root="https://openklant.maykin.nl/klantinteracties", token="your_api_token")

# Get user data
partijen = client.Partij.list()
print(partijen)
```

## Testing

### Re-recording VCR cassettes

The tests rely on VCR cassettes which are included in the repo. To dynamically create
an OpenKlant service and run the tests against it, run the following command:

```bash
$ cd src/openklant2
$ ./regenerate_vcr_fixtures.sh
```
3 changes: 3 additions & 0 deletions src/openklant2/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from openklant2.client import OpenKlant2Client

__all__ = ["OpenKlant2Client"]
Empty file.
155 changes: 155 additions & 0 deletions src/openklant2/_resources/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import json
from typing import Any, Dict, List, Mapping, MutableMapping, TypeGuard, TypeVar, Union

import pydantic
import requests
from ape_pie import APIClient

from openklant2.exceptions import (
BadRequest,
Forbidden,
InvalidJSONResponse,
NonJSONResponse,
NotFound,
ResponseError,
StructuredErrorResponse,
Unauthorized,
)
from openklant2.types.error import (
ErrorResponseBodyValidator,
ValidationErrorResponseBodyValidator,
)

T = TypeVar("T")

ResourceResponse = MutableMapping[str, Any]


JSONPrimitive = Union[str, int, None, float]
JSONValue = Union[JSONPrimitive, "JSONObject", List["JSONValue"]]
JSONObject = Dict[str, JSONValue]


class ResourceMixin:
http_client: APIClient

def __init__(self, http_client: APIClient):
self.http_client = http_client

@staticmethod
def process_response(response: requests.Response) -> TypeGuard[JSONValue]:
response_data = None
try:
content_type = response.headers.get("Content-Type", "")
# Note: there are currently no non-JSON responses defined in the
# spec, the obvious example would be e.g. something like a blob
# download. Until such endpoints are encountered, we treat non-JSON
# as an error.
if not content_type.lower().startswith("application/json"):
raise NonJSONResponse(response)

response_data = response.json()
except (requests.exceptions.JSONDecodeError, json.JSONDecodeError):
raise InvalidJSONResponse(response)

match response.status_code:
case code if code >= 200 and code < 300 and response_data:
return response_data
case code if code >= 400 and code < 500 and response_data:
validator = ErrorResponseBodyValidator
exc_class = StructuredErrorResponse
match code:
case 400:
validator = ValidationErrorResponseBodyValidator
exc_class = BadRequest
case 401:
exc_class = Unauthorized
case 403:
exc_class = Forbidden
case 404:
exc_class = NotFound
case _:
pass

try:
validator.validate_python(response_data)
raise exc_class(response, response_data)
except pydantic.ValidationError:
# JSON body, but not in an expected schema. Fall through to generic ErrorResponse
pass
case _:
pass

raise ResponseError(response, msg="Error response")

def _get(
self,
path: str,
headers: Mapping | None = None,
params: Mapping | None = None,
) -> requests.Response:

return self.http_client.request("get", path, headers=headers, params=params)

def _post(
self,
path: str,
headers: Mapping | None = None,
params: Mapping | None = None,
data: Any = None,
) -> requests.Response:
return self.http_client.request(
"post", path, headers=headers, json=data, params=params
)

def _put(
self,
path: str,
headers: Mapping | None = None,
params: Mapping | None = None,
data: Any = None,
) -> requests.Response:
return self.http_client.request(
"put", path, headers=headers, json=data, params=params
)

def _delete(
self,
path: str,
headers: Mapping | None = None,
params: Mapping | None = None,
) -> requests.Response:
return self.http_client.request(
"delete",
path,
headers=headers,
params=params,
)

def _patch(
self,
path: str,
headers: Mapping | None = None,
params: Mapping | None = None,
data: Any = None,
) -> requests.Response:
return self.http_client.request(
"patch",
path,
headers=headers,
params=params,
json=data,
)

def _options(
self,
path: str,
headers: Mapping | None = None,
params: Mapping | None = None,
) -> requests.Response:
return self.http_client.request(
"delete",
path,
headers=headers,
params=params,
)
Loading
Loading