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

Change DynamoDBCacheBackend to handle floats #42

Merged
merged 9 commits into from
Oct 13, 2023
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
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .[test,dev,aws]
pip install -e .[test,dev,orjson,aws]
- name: Pytest
run: |
pytest
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# CHANGELOG

## UNRELEASED

- Added `CacheSerializer`, `NoopCacheSerializer` and `JSONCacheSerializer`. Changed `CacheBackend`, `InMemoryCache`, `CloudflareCacheBackend` and `DynamoDBCacheBackend` to accept `serializer` initialization option.


## 0.2.0 (2023-09-25)

- Added `CloudflareCacheBackend`.
Expand Down
29 changes: 28 additions & 1 deletion GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ To enable cache, `cache` and `cache_key` need to be set.

### Custom cache backends

Custom cache backends should extend `ariadne_graphql_proxy.cache.CacheBackend` class and need to implement `set` and `get` methods:
Custom cache backends should extend `ariadne_graphql_proxy.cache.CacheBackend` class and need to implement `set` and `get` methods:

```python
class CacheBackend:
Expand All @@ -641,6 +641,15 @@ class CacheBackend:
...
```

`__init__` methods takes `serializer` argument which defaults to `NoopCacheSerializer()` and can be overriden:

```python
class CacheBackend:
def __init__(self, serializer: Optional[CacheSerializer] = None) -> None:
self.serializer: CacheSerializer = serializer or NoopCacheSerializer()
```


They can also optionally implement `clear_all` method, but its not used by Ariadne GraphQL Proxy outside of tests:

```python
Expand All @@ -650,6 +659,22 @@ class CacheBackend:
```


### Cache serializers

Currently only `NoopCacheSerializer` and `JSONCacheSerializer` are provided, but developers may implement their own serializers.

Custom cache serializers should extend `ariadne_graphql_proxy.cache.CacheSerializer` class and need to implement `serialize` and `deserialize` methods:

```python
class CacheSerializer:
def serialize(self, value: Any) -> str:
...

def deserialize(self, value: str) -> Any:
...
```


### `CloudflareCacheBackend`

`CloudflareCacheBackend` uses Cloudflare's [key value storage](https://developers.cloudflare.com/workers/learning/how-kv-works/) for caching. It can be imported from `ariadne_graphql_proxy.contrib.cloudflare` and requires following arguments:
Expand All @@ -658,6 +683,7 @@ class CacheBackend:
- `namespace_id`: `str`: Id of worker's KV Namespace.
- `headers`: `Optional[Dict[str, str]]`: Headers attached to every api call, defaults to `{}`.
- `base_url`: `str`: Cloudflare API base url, defaults to `"https://api.cloudflare.com/client/v4"`.
- `serializer`: `Optional[CacheSerializer]`: serialiser used to process cached and retrieved values, defaults to `ariadne_graphql_proxy.cache.JSONCacheSerializer()`.

```python
from ariadne_graphql_proxy.contrib.cloudflare import CloudflareCacheBackend
Expand Down Expand Up @@ -687,6 +713,7 @@ It can be imported from `ariadne_graphql_proxy.contrib.aws` and requires followi
- `partition_key`: `str`: Partition key, defaults to `key`.
- `ttl_attribute`: `str`: TTL attribute, defaults to `ttl`.
- `session`: `Optional[Session]`: Instance of `boto3.session.Session`, defaults to `Session()` which reads configuration values according to these [docs](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html#guide-configuration).
- `serializer`: `Optional[CacheSerializer]`: serialiser used to process cached and retrieved values, defaults to `ariadne_graphql_proxy.cache.JSONCacheSerializer()`.

```python
from ariadne_graphql_proxy.contrib.aws import DynamoDBCacheBackend
Expand Down
4 changes: 4 additions & 0 deletions ariadne_graphql_proxy/cache/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
get_simple_cache_key,
)
from .cached_resolver import cached_resolver
from .serializer import CacheSerializer, JSONCacheSerializer, NoopCacheSerializer
from .simple_cached_resolver import simple_cached_resolver

__all__ = [
Expand All @@ -17,4 +18,7 @@
"get_operation_cache_key",
"get_simple_cache_key",
"simple_cached_resolver",
"CacheSerializer",
"JSONCacheSerializer",
"NoopCacheSerializer",
]
9 changes: 8 additions & 1 deletion ariadne_graphql_proxy/cache/backend.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
from time import time
from typing import Any, Dict, Optional, Tuple

from .serializer import CacheSerializer, NoopCacheSerializer


class CacheBackend:
def __init__(self, serializer: Optional[CacheSerializer] = None) -> None:
self.serializer: CacheSerializer = serializer or NoopCacheSerializer()

async def set(self, key: str, value: Any, ttl: Optional[int] = None):
raise NotImplementedError("Cache backends need to define custom 'set' method.")

Expand All @@ -18,7 +23,9 @@ async def clear_all(self):
class InMemoryCache(CacheBackend):
_cache: Dict[str, Tuple[Any, Optional[int]]]

def __init__(self):
def __init__(self, serializer: Optional[CacheSerializer] = None):
super().__init__(serializer)

self._cache = {}

async def set(self, key: str, value: Any, ttl: Optional[int] = None):
Expand Down
41 changes: 41 additions & 0 deletions ariadne_graphql_proxy/cache/serializer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from typing import Any

try:
import orjson as json

USING_ORJSON = True
except ImportError:
import json # type: ignore[no-redef]

USING_ORJSON = False


class CacheSerializer:
def serialize(self, value: Any) -> str:
raise NotImplementedError(
"Cache serializer needs to define custom 'serialize' method."
)

def deserialize(self, value: str) -> Any:
raise NotImplementedError(
"Cache serializer needs to define custom 'deserialize' method."
)


class NoopCacheSerializer(CacheSerializer):
def serialize(self, value: Any) -> str:
return value

def deserialize(self, value: str) -> Any:
return value


class JSONCacheSerializer(CacheSerializer):
def serialize(self, value: Any) -> str:
if USING_ORJSON:
return json.dumps(value).decode()

return json.dumps(value) # type: ignore

def deserialize(self, value: str) -> Any:
return json.loads(value)
16 changes: 13 additions & 3 deletions ariadne_graphql_proxy/contrib/aws/cache_backend.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import time
from typing import Any, Optional

from ariadne_graphql_proxy.cache import CacheBackend
from ariadne_graphql_proxy.cache import (
CacheBackend,
CacheSerializer,
JSONCacheSerializer,
)

try:
from asgiref.sync import sync_to_async
Expand All @@ -24,7 +28,10 @@ def __init__(
partition_key: str = "key",
ttl_attribute: str = "ttl",
session: Optional[Session] = None,
serializer: Optional[CacheSerializer] = None,
) -> None:
super().__init__(serializer or JSONCacheSerializer())

self.table_name = table_name
self.partition_key = partition_key
self.ttl_attribute = ttl_attribute
Expand Down Expand Up @@ -60,7 +67,10 @@ def _query_by_key(self, key: str, max_ttl: int) -> dict:
)

async def set(self, key: str, value: Any, ttl: Optional[int] = None):
item = {self.partition_key: key, self.value_attribute_name: value}
item: dict[str, Any] = {
self.partition_key: key,
self.value_attribute_name: self.serializer.serialize(value),
}
if ttl:
now = int(time.time())
item[self.ttl_attribute] = now + ttl
Expand All @@ -74,7 +84,7 @@ async def get(self, key: str, default: Any = None) -> Any:
if len(items) < 1:
return default

return items[0].get(self.value_attribute_name, default)
return self.serializer.deserialize(items[0][self.value_attribute_name])

async def clear_all(self):
pass
14 changes: 10 additions & 4 deletions ariadne_graphql_proxy/contrib/cloudflare/cache_backend.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import json
from typing import Any, Dict, Optional

import httpx

from ...cache import CacheBackend
from ariadne_graphql_proxy.cache import (
CacheBackend,
CacheSerializer,
JSONCacheSerializer,
)


class CloudflareCacheError(Exception):
Expand All @@ -25,7 +28,10 @@ def __init__(
namespace_id: str,
headers: Optional[Dict[str, str]] = None,
base_url: str = "https://api.cloudflare.com/client/v4",
serializer: Optional[CacheSerializer] = None,
) -> None:
super().__init__(serializer or JSONCacheSerializer())

self.base_url = base_url
self.account_id = account_id
self.namespace_id = namespace_id
Expand All @@ -51,7 +57,7 @@ async def set(self, key: str, value: Any, ttl: Optional[int] = None):
f"accounts/{self.account_id}/"
f"storage/kv/namespaces/{self.namespace_id}/"
f"values/{key}",
files={"value": json.dumps({"value": value}), "metadata": "{}"},
files={"value": self.serializer.serialize(value), "metadata": "{}"},
params={"expiration_ttl": ttl} if ttl is not None else {},
)

Expand All @@ -66,7 +72,7 @@ async def get(self, key: str, default: Any = None) -> Any:
)

if response.is_success:
return response.json()["value"]
return self.serializer.deserialize(response.content.decode())

return default

Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ test = [
"ruff",
]

orjson = [
"orjson",
]

aws = [
"asgiref",
"boto3",
Expand Down
55 changes: 55 additions & 0 deletions tests/cache/test_serializer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import pytest

from ariadne_graphql_proxy.cache import JSONCacheSerializer, NoopCacheSerializer


@pytest.fixture
def mocked_lib(mocker):
return mocker.patch("ariadne_graphql_proxy.cache.serializer.json")


@pytest.fixture
def mocked_json(mocker, mocked_lib):
mocker.patch("ariadne_graphql_proxy.cache.serializer.USING_ORJSON", False)
return mocked_lib


@pytest.fixture
def mocked_orjson(mocker, mocked_lib):
mocker.patch("ariadne_graphql_proxy.cache.serializer.USING_ORJSON", True)
return mocked_lib


def test_noop_serialize_returns_not_changed_value():
assert NoopCacheSerializer().serialize("abc") == "abc"


def test_noop_deserialize_returns_not_changed_value():
assert NoopCacheSerializer().deserialize("abc") == "abc"


def test_json_serialize_calls_json_dumps_with_decode_if_orjson_is_available(
mocked_orjson,
):
JSONCacheSerializer().serialize("test value")

assert mocked_orjson.dumps.called_with("test value")
assert mocked_orjson.dumps().decode.called


def test_json_serialize_calls_json_dumps_if_orjson_is_not_available(mocked_json):
JSONCacheSerializer().serialize("test value")

assert mocked_json.dumps.called_with("test value")


def test_json_deserialize_calls_orjson_loads_if_orjson_is_available(mocked_orjson):
JSONCacheSerializer().deserialize("test value")

assert mocked_orjson.loads.called_with("test value")


def test_json_deserialize_calls_json_loads_if_orjson_is_not_available(mocked_json):
JSONCacheSerializer().deserialize("test value")

assert mocked_json.loads.called_with("test value")
Loading