Skip to content

Commit

Permalink
Initial version of the OpenKlant2 client
Browse files Browse the repository at this point in the history
  • Loading branch information
swrichards committed Sep 4, 2024
1 parent 2b1d3a9 commit 0cc8277
Show file tree
Hide file tree
Showing 32 changed files with 2,331 additions and 0 deletions.
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.
142 changes: 142 additions & 0 deletions src/openklant2/_resources/base.py
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,
)
62 changes: 62 additions & 0 deletions src/openklant2/_resources/partij.py
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))
16 changes: 16 additions & 0 deletions src/openklant2/client.py
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)
36 changes: 36 additions & 0 deletions src/openklant2/docker-compose.yaml
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
75 changes: 75 additions & 0 deletions src/openklant2/exceptions.py
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}',
)
11 changes: 11 additions & 0 deletions src/openklant2/factories/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from .partij import (
CreatePartijContactPersoonDataFactory,
CreatePartijOrganisatieDataFactory,
CreatePartijPersoonDataFactory,
)

__all__ = [
"CreatePartijPersoonDataFactory",
"CreatePartijOrganisatieDataFactory",
"CreatePartijContactPersoonDataFactory",
]
15 changes: 15 additions & 0 deletions src/openklant2/factories/helpers.py
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
Loading

0 comments on commit 0cc8277

Please sign in to comment.