-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Initial version of the OpenKlant2 client
- Loading branch information
1 parent
2b1d3a9
commit 0cc8277
Showing
32 changed files
with
2,331 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
from openklant2.client import OpenKlant2Client | ||
|
||
__all__ = ["OpenKlant2Client"] |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,142 @@ | ||
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, | ||
ClientError, | ||
GenericErrorResponse, | ||
NonJSONResponse, | ||
NotFound, | ||
) | ||
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 | ||
|
||
def process_response(self, response: requests.Response) -> TypeGuard[JSONValue]: | ||
response_data = None | ||
try: | ||
content_type = response.headers.get("Content-Type", "") | ||
if not content_type.lower().startswith("application/json"): | ||
raise NonJSONResponse(response) | ||
|
||
response_data = response.json() | ||
except (requests.exceptions.JSONDecodeError, json.JSONDecodeError): | ||
raise NonJSONResponse(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: | ||
match code: | ||
case 400: | ||
validator = ValidationErrorResponseBodyValidator | ||
exc_class = BadRequest | ||
case 404: | ||
validator = ErrorResponseBodyValidator | ||
exc_class = NotFound | ||
case _: | ||
validator = ErrorResponseBodyValidator | ||
exc_class = ClientError | ||
|
||
try: | ||
validator.validate_python(response_data) | ||
raise exc_class(response_data) | ||
except pydantic.ValidationError: | ||
raise GenericErrorResponse(response=response) | ||
case _: | ||
raise GenericErrorResponse(response) | ||
|
||
raise | ||
|
||
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, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import uuid | ||
from typing import Optional, cast | ||
|
||
from ape_pie import APIClient | ||
|
||
from openklant2._resources.base import ResourceMixin | ||
from openklant2.types.pagination import PaginatedResponseBody | ||
from openklant2.types.resources.partij import ( | ||
CreatePartijContactpersoonData, | ||
CreatePartijOrganisatieData, | ||
CreatePartijPersoonData, | ||
Partij, | ||
PartijListParams, | ||
PartijRetrieveParams, | ||
) | ||
|
||
|
||
class PartijResource(ResourceMixin): | ||
http_client: APIClient | ||
base_path: str = "/partijen" | ||
|
||
def list( | ||
self, *, params: Optional[PartijListParams] = None | ||
) -> PaginatedResponseBody[Partij]: | ||
response = self._get(self.base_path, params=params) | ||
return cast(PaginatedResponseBody[Partij], self.process_response(response)) | ||
|
||
def retrieve( | ||
self, /, uuid: str | uuid.UUID, *, params: Optional[PartijRetrieveParams] = None | ||
) -> Partij: | ||
response = self._get(f"{self.base_path}/{str(uuid)}", params=params) | ||
return cast(Partij, self.process_response(response)) | ||
|
||
# Partij is polymorphic on "soortPartij", with varying fields for Persoon, Organisatie and ContactPersoon | ||
def create_organisatie(self, *, data: CreatePartijOrganisatieData): | ||
return self._create(data=data) | ||
|
||
def create_persoon( | ||
self, | ||
*, | ||
data: CreatePartijPersoonData, | ||
) -> Partij: | ||
return cast(Partij, self._create(data=data)) | ||
|
||
def create_contactpersoon( | ||
self, | ||
*, | ||
data: CreatePartijContactpersoonData, | ||
) -> Partij: | ||
return cast(Partij, self._create(data=data)) | ||
|
||
def _create( | ||
self, | ||
*, | ||
data: ( | ||
CreatePartijPersoonData | ||
| CreatePartijOrganisatieData | ||
| CreatePartijContactpersoonData | ||
), | ||
) -> Partij: | ||
response = self._post(self.base_path, data=data) | ||
return cast(Partij, self.process_response(response)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
from ape_pie import APIClient | ||
|
||
from openklant2._resources.partij import PartijResource | ||
|
||
|
||
class OpenKlant2Client: | ||
http_client: APIClient | ||
partij: PartijResource | ||
|
||
def __init__(self, token: str, api_root: str): | ||
self.http_client = APIClient( | ||
request_kwargs={"headers": {"Authorization": f"Token {token}"}}, | ||
base_url=api_root, | ||
) | ||
|
||
self.partij = PartijResource(self.http_client) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
# This docker-compose is used internally in the testsuite to dynamically spin up | ||
# an OpenKlant2 backend, seed it with known data, and record VCR fixtures. Be | ||
# sure to verify any changes here are consistent with | ||
version: '3' | ||
|
||
services: | ||
db: | ||
image: postgres | ||
environment: | ||
- POSTGRES_HOST_AUTH_METHOD=trust | ||
|
||
web: | ||
image: maykinmedia/open-klant:${OPEN_KLANT_IMAGE_TAG:-latest} | ||
environment: &web-env | ||
- DJANGO_SETTINGS_MODULE=openklant.conf.docker | ||
- IS_HTTPS=no | ||
- DB_NAME=postgres | ||
- DB_USER=postgres | ||
- DB_HOST=db | ||
- ALLOWED_HOSTS=* | ||
- CACHE_DEFAULT=redis:6379/0 | ||
- CACHE_AXES=redis:6379/0 | ||
- SUBPATH=${SUBPATH:-/} | ||
- SECRET_KEY=${SECRET_KEY:-django-insecure-f8s@b*ds4t84-q_2#c0j0506@!l2q6r5_pq5e!vm^_9c*#^66b} | ||
- CELERY_BROKER_URL=redis://redis:6379/0 | ||
- CELERY_RESULT_BACKEND=redis://redis:6379/0 | ||
- DISABLE_2FA=true | ||
ports: | ||
# Use a somewhat arbitrary port to avoid clashes | ||
- 8338:8000 | ||
depends_on: | ||
- db | ||
- redis | ||
|
||
redis: | ||
image: redis |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
import pprint | ||
|
||
from requests import Response | ||
|
||
from openklant2.types.error import ( | ||
ErrorResponseBody, | ||
InvalidParam, | ||
ValidationErrorResponseBody, | ||
) | ||
|
||
|
||
class OpenKlant2Exception(Exception): | ||
pass | ||
|
||
|
||
class GenericErrorResponse(OpenKlant2Exception): | ||
response: Response | ||
|
||
def __init__(self, response: Response): | ||
self.response = response | ||
|
||
|
||
class NonJSONResponse(GenericErrorResponse): | ||
pass | ||
|
||
|
||
class ClientError(OpenKlant2Exception): | ||
|
||
type: str | ||
code: str | ||
title: str | ||
status: int | ||
detail: str | ||
instance: str | ||
|
||
def __init__(self, response: ErrorResponseBody): | ||
self.type = response["type"] | ||
self.code = response["code"] | ||
self.title = response["title"] | ||
self.status = response["status"] | ||
self.detail = response["detail"] | ||
self.instance = response["instance"] | ||
|
||
OpenKlant2Exception.__init__( | ||
self, f'status={self.status} code={self.code} title="{self.title}"' | ||
) | ||
|
||
|
||
class NotFound(ClientError): | ||
pass | ||
|
||
|
||
class BadRequest(ClientError): | ||
invalidParams: list[InvalidParam] | ||
|
||
def __init__(self, response: ValidationErrorResponseBody): | ||
self.type = response["type"] | ||
self.code = response["code"] | ||
self.title = response["title"] | ||
self.status = response["status"] | ||
self.detail = response["detail"] | ||
self.instance = response["instance"] | ||
self.invalidParams = response["invalidParams"] | ||
|
||
invalid_params_formatted = "" | ||
if self.invalidParams: | ||
invalid_params_formatted = "\nInvalid parameters:\n" | ||
invalid_params_formatted += "\n".join( | ||
pprint.pformat(param) for param in self.invalidParams | ||
) | ||
|
||
OpenKlant2Exception.__init__( | ||
self, | ||
f'status={self.status} code={self.code} title="{self.title}":{invalid_params_formatted}', | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
from .partij import ( | ||
CreatePartijContactPersoonDataFactory, | ||
CreatePartijOrganisatieDataFactory, | ||
CreatePartijPersoonDataFactory, | ||
) | ||
|
||
__all__ = [ | ||
"CreatePartijPersoonDataFactory", | ||
"CreatePartijOrganisatieDataFactory", | ||
"CreatePartijContactPersoonDataFactory", | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import factory | ||
from pydantic import TypeAdapter | ||
|
||
|
||
def validate_against(validator: TypeAdapter): | ||
def decorator(cls: type[factory.Factory]): | ||
@factory.post_generation | ||
def validate(obj, *args, **kwargs): | ||
validator.validate_python(obj) | ||
|
||
setattr(cls, "post_generation_validator", validate) | ||
|
||
return cls | ||
|
||
return decorator |
Oops, something went wrong.