Skip to content

Commit

Permalink
Feature/add cache decorator (#313)
Browse files Browse the repository at this point in the history
- [x] Test on real app
- [x] Clean code
- [x] Write forgotten test about change default settings value for cache
- [x] Test automatique wrapper around django decorator
- [x] Implement an auto clean cache decorator from django signals
- [x] Write documentation*
- [x] Make changelog. Put inside the inheritance of django
request/response. The headers proxy, the http_to_grpc_decorator, cache
feature and the fact that metadata in test should match real grpc
convention, drop support of django version and python versions

---------

Co-authored-by: Léni <[email protected]>
  • Loading branch information
AMontagu and legau authored Sep 27, 2024
1 parent b6ec1c7 commit 954fb56
Show file tree
Hide file tree
Showing 40 changed files with 4,269 additions and 602 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/lint-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@ jobs:
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: |
poetry install
poetry add django@^${{ matrix.django-version }}
- name: Run pre-commit hooks
run: |
poetry run pre-commit run --all-files --show-diff-on-failure
- name: Run tests
run: |
poetry add django@^${{ matrix.django-version }}
poetry run tests
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
- Dropping not working support for Django < 4
- Dropping support for python < 3.10
- Add Django version into the CI to assure that supporting version still works
- Made Internal Proxy Request/Response inherit from django Request/Response
- Create Request Meta and Response Headers interceptor/proxy to be able to get/set correctly headers for both request and response
- Create the http_to_grpc decorator to provide a way to use django decorator in DSG
- Add cache_enpoint, cache_endpoint_with_deleter, vary_on_metadata decorator
- Stop allowing metadata that doesn't have lower case key and metadata values that are not str or bytes in FakeGrpc testing tool

## 0.22.9

Expand Down
4 changes: 4 additions & 0 deletions django_socio_grpc/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@
class DjangoSocioGrpcConfig(AppConfig):
name = "django_socio_grpc"
verbose_name = "Django Socio gRPC"

def ready(self):
# Import signals module to connect the signals
pass
295 changes: 295 additions & 0 deletions django_socio_grpc/decorators.py

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion django_socio_grpc/grpc_actions/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
)
from django_socio_grpc.request_transformer.grpc_internal_proxy import GRPCInternalProxyContext
from django_socio_grpc.settings import grpc_settings
from django_socio_grpc.signals import grpc_action_register
from django_socio_grpc.utils.debug import ProtoGeneratorPrintHelper
from django_socio_grpc.utils.utils import _is_generator, isgeneratorfunction

Expand Down Expand Up @@ -124,6 +125,9 @@ def __set_name__(self, owner, name):
owner._decorated_grpc_action_registry = {}
owner._decorated_grpc_action_registry.update({name: self.get_action_params()})

def __hash__(self):
return hash(self.function)

def __get__(self, obj, type=None):
return self.clone(function=self.function.__get__(obj, type))

Expand Down Expand Up @@ -224,7 +228,6 @@ def register(self, owner: type["Service"], action_name: str):
ProtoGeneratorPrintHelper.set_service_and_action(
service_name=owner.__name__, action_name=action_name
)
ProtoGeneratorPrintHelper.print("register ", owner.__name__, action_name)
try:
self.resolve_placeholders(owner, action_name)

Expand All @@ -242,6 +245,9 @@ def register(self, owner: type["Service"], action_name: str):

setattr(owner, action_name, self)

# INFO - AM - 22/08/2024 - Send a signal to notify that a grpc action has been created. Used for now in cache deleter
grpc_action_register.send(sender=self, owner=owner, name=action_name)

def resolve_placeholders(self, service_class: type["Service"], action: str):
"""
Iterate over the `GRPCAction` attributes and resolve all the placeholder instances
Expand Down
2 changes: 2 additions & 0 deletions django_socio_grpc/management/commands/grpcrunaioserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ def handle(self, *args, **options):

# set GRPC_ASYNC to "true" in order to start server asynchronously
grpc_settings.GRPC_ASYNC = True
# INFO - AM - 25/07/2025 - Make sure that the port in the settings is the correct one
grpc_settings.GRPC_CHANNEL_PORT = self.address.split(":")[-1]

asyncio.run(self.run(**options))

Expand Down
2 changes: 1 addition & 1 deletion django_socio_grpc/middlewares.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def locale_middleware(get_response: Callable):
"""
Middleware to activate i18n language in django.
It will look for an Accept-Language formated metadata in the headers key.
metadata = ('headers', ('Accept-Language', 'fr-CH, fr;q=0.9, en;q=0.8, de;'))
metadata = ('headers', ('accept-language', 'fr-CH, fr;q=0.9, en;q=0.8, de;'))
"""
if asyncio.iscoroutinefunction(get_response):

Expand Down
172 changes: 166 additions & 6 deletions django_socio_grpc/request_transformer/grpc_internal_proxy.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from dataclasses import dataclass
from typing import Optional

from django.utils.datastructures import (
CaseInsensitiveMapping,
)
from google.protobuf.message import Message
from grpc.aio import ServicerContext
from grpc.aio._typing import ResponseType

from .socio_internal_request import InternalHttpRequest
from .socio_internal_response import InternalHttpResponse
Expand All @@ -19,10 +22,13 @@ class GRPCInternalProxyContext:
# INFO - AM - 14/02/2024 - grpc_request is used to get filter and pagination from the request. It is not acessible in GRPCInternalProxyContext.
grpc_request: Message
grpc_action: str
service_class_name: str
http_request: InternalHttpRequest = None

def __post_init__(self):
self.http_request = InternalHttpRequest(self, self.grpc_request, self.grpc_action)
self.http_request = InternalHttpRequest(
self, self.grpc_request, self.grpc_action, self.service_class_name
)

def __getattr__(self, attr):
if hasattr(self.grpc_context, attr):
Expand All @@ -38,17 +44,104 @@ class GRPCInternalProxyResponse:
Need to be improved if some specific behavior needed, for example injecting some data in the reponse metadata.
"""

grpc_response: ResponseType
http_response: InternalHttpResponse = None
grpc_response: Message
# INFO - AM - 25/07/2024 - grpc context is used to pass response header to client
grpc_context: ServicerContext
# INFO - AM - 01/08/2024 - http_response is created in post_init signals. Don't need to pass it in the constructor if defautl behavior wanted.
http_response: InternalHttpResponse | None = None
# INFO - AM - 01/08/2024 - headers is created in post_init signals. Don't need to pass it in the constructor if defautl behavior wanted.
headers: Optional["ResponseHeadersProxy"] = None

def __post_init__(self):
self.http_response = InternalHttpResponse()
self.headers = ResponseHeadersProxy(self.grpc_context, self.http_response)

def __getattr__(self, attr):
"""
This private method is used to correctly distribute the attribute access between the proxy, th grpc_response and the http_response
"""
if attr in self.__annotations__:
return super().__getattribute__(attr)
if hasattr(self.grpc_response, attr):
return getattr(self.grpc_response, attr)
return getattr(self.http_response, attr)

def __delitem__(self, header):
"""
Allow to treat GRPCInternalProxyResponse as a dict of headers as django.http.HttpResponse does
"""
return self.headers.__delitem__(header)

def __setitem__(self, key, value):
"""
Allow to treat GRPCInternalProxyResponse as a dict of headers as django.http.HttpResponse does
"""
return self.headers.__setitem__(key, value)

def __getitem__(self, header):
"""
Allow to treat GRPCInternalProxyResponse as a dict of headers as django.http.HttpResponse does
"""
return self.headers.__getitem__(header)

def has_header(self, header):
"""Case-insensitive check for a header."""
return header in self.headers

__contains__ = has_header

def get(self, key, default=None):
"""
Allow to treat GRPCInternalProxyResponse as a dict of headers as django.http.HttpResponse does
"""
return self.headers.get(key, default)

def items(self):
"""
Allow to treat GRPCInternalProxyResponse as a dict of headers as django.http.HttpResponse does
"""
return self.headers.items()

def setdefault(self, key, value):
"""
Allow to treat GRPCInternalProxyResponse as a dict of headers as django.http.HttpResponse does
Set a header unless it has already been set.
"""
self.headers.setdefault(key, value)

def __getstate__(self):
"""
Allow to serialize the object mainly for cache purpose
"""
return {
"grpc_response": self.grpc_response,
"http_response": self.http_response,
"response_metadata": dict(self.grpc_context.trailing_metadata()),
}

def __repr__(self):
return f"GRPCInternalProxyResponse<{self.grpc_response.__repr__()}, {self.http_response.__repr__()}>"

def __setstate__(self, state):
"""
Allow to deserialize the object mainly for cache purpose.
When used in cache, the grpc_context is not set. To be correctly use set_current_context method should be called
"""
self.grpc_response = state["grpc_response"]
self.http_response = state["http_response"]
self.headers = ResponseHeadersProxy(
None, self.http_response, metadata=state["response_metadata"]
)
self.grpc_context = None

def set_current_context(self, grpc_context: ServicerContext):
"""
This method is used to set the current context to the response object when fetched from cache
It also enable the copy of the cache metdata
"""
self.grpc_context = grpc_context
self.headers.set_grpc_context(grpc_context)

def __aiter__(self):
return self

Expand All @@ -57,7 +150,7 @@ async def __anext__(self):
Used to iterate over the proxy to the grpc_response as the http response can't be iterate over
"""
next_item = await self.grpc_response.__anext__()
return GRPCInternalProxyResponse(next_item)
return GRPCInternalProxyResponse(next_item, self.grpc_context)

def __iter__(self):
return self
Expand All @@ -67,4 +160,71 @@ def __next__(self):
Used to iterate over the proxy to the grpc_response as the http response can't be iterate over
"""
next_item = self.grpc_response.__next__()
return GRPCInternalProxyResponse(next_item)
return GRPCInternalProxyResponse(next_item, self.grpc_context)


@dataclass
class ResponseHeadersProxy(CaseInsensitiveMapping):
"""
Class that allow us to write headers both in grpc metadata and http response to keep compatibility with django system
See https://github.com/django/django/blob/main/django/http/response.py#L32 for inspiration
"""

grpc_context: ServicerContext | None
http_response: InternalHttpResponse
# INFO - AM - 31/07/2024 - Only used when restoring from cache. Do not use it directly.
metadata: dict | None = None

def __post_init__(self):
if self.grpc_context is None and self.metadata is None:
raise ValueError("grpc_context or metadata must be set for ResponseHeadersProxy")
if self.grpc_context is not None:
metadata_as_dict = dict(self.grpc_context.trailing_metadata())
else:
metadata_as_dict = self.metadata
self.http_response.headers = metadata_as_dict
super().__init__(data=metadata_as_dict)

def set_grpc_context(self, grpc_context: ServicerContext):
"""
When GRPCInternalProxyResponse is created from cache it doesn't have a grpc_context.
This method is used to set it after the object is created and merge their current metadata with the one cached
"""
self.grpc_context = grpc_context
existing_metadata = dict(grpc_context.trailing_metadata())

trailing_metadata_dict = {**existing_metadata, **self.http_response.headers}
# INFO - AM - 01/08/2024 - We need to convert all the values to string if not bytes as grpc metadata only accept string and bytes
# We also need to convert all the keys to lower case as grpc metadata expect lower metadata keys
trailing_metadata = [
(key.lower(), str(value) if not isinstance(value, bytes) else value)
for key, value in trailing_metadata_dict.items()
]
self.grpc_context.set_trailing_metadata(trailing_metadata)

def setdefault(self, key, value):
if key not in self:
self[key] = value

def __setitem__(self, key, value):
if self.grpc_context:
trailing_metadata = self.grpc_context.trailing_metadata()
self.grpc_context.set_trailing_metadata(
trailing_metadata + ((key.lower(), str(value)),)
)
self.http_response[key] = value
self._store[key.lower()] = (key, value)

def __delitem__(self, header):
if self.grpc_context:
new_metadata = [
(k, v)
for k, v in self.grpc_context.trailing_metadata()
if k.lower() != header.lower()
]
self.grpc_context.set_trailing_metadata(new_metadata)
del self.http_response[header]
super().__delitem__(header)

def __repr__(self):
return super().__repr__()
Loading

0 comments on commit 954fb56

Please sign in to comment.