Skip to content
Open
22 changes: 22 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@ Changelog
=========


Version 0.5
-----------

- Update pagination response format
pageSize -> page_size
totalPages -> total_pages


Version 0.4
-----------

- Add docs
- Fix bugs


Version 0.3
-----------

- Added exception handler
- Added pagination


Version 0.2
-----------

Expand Down
6 changes: 3 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ Django gRPC Framework
.. image:: https://readthedocs.org/projects/djangogrpcframework/badge/?version=latest
:target: https://readthedocs.org/projects/djangogrpcframework/badge/?version=latest

.. image:: https://travis-ci.org/fengsp/django-grpc-framework.svg?branch=master
:target: https://travis-ci.org/fengsp/django-grpc-framework.svg?branch=master
.. image:: https://travis-ci.org/algori-io/django-grpc-framework.svg?branch=master
:target: https://travis-ci.org/algori-io/django-grpc-framework.svg?branch=master

.. image:: https://img.shields.io/pypi/pyversions/djangogrpcframework
:target: https://img.shields.io/pypi/pyversions/djangogrpcframework
Expand Down Expand Up @@ -58,7 +58,7 @@ model-backed service for accessing users, startup a new project:

Generate ``.proto`` file demo.proto_:

.. _demo.proto: https://github.com/fengsp/django-grpc-framework/blob/master/examples/demo/demo.proto
.. _demo.proto: https://github.com/algori-io/django-grpc-framework/blob/master/examples/demo/demo.proto

.. code-block:: bash

Expand Down
4 changes: 2 additions & 2 deletions TODO
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ TODO
====

- relationships
- error handling
- add BulkCreate\BulkDestroy\BulkUpdate with stream-unary mode ?
- authentication
- logging
- add testcases for project
- filter support ?
- filter support ?
- google.protobuf.FieldMask support
2 changes: 1 addition & 1 deletion django_grpc_framework/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '0.2'
__version__ = '0.5'
41 changes: 37 additions & 4 deletions django_grpc_framework/generics.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from django.db.models.query import QuerySet
from django.shortcuts import get_object_or_404
import grpc
from django.core.exceptions import ValidationError
from django.db.models.query import QuerySet
from django.http import Http404
import grpc
from django.shortcuts import get_object_or_404

from django_grpc_framework.utils import model_meta
from django_grpc_framework import mixins, services
from django_grpc_framework.settings import grpc_settings
from django_grpc_framework.utils import model_meta


class GenericService(services.Service):
Expand All @@ -20,6 +21,9 @@ class GenericService(services.Service):
lookup_field = None
lookup_request_field = None

# The style to use for queryset pagination.
pagination_class = None

def get_queryset(self):
"""
Get the list of items for this service.
Expand Down Expand Up @@ -112,6 +116,35 @@ def filter_queryset(self, queryset):
"""Given a queryset, filter it, returning a new queryset."""
return queryset

@property
def paginator(self):
"""
The paginator instance associated with the view, or `None`.
"""
if not hasattr(self, '_paginator'):
if self.pagination_class is None:
self._paginator = None
else:
self._paginator = self.pagination_class()
return self._paginator

def paginate_queryset(self, queryset):
"""
Return a single page of results, or `None` if pagination is disabled.
"""
if self.paginator is None:
return None
return self.paginator.paginate_queryset(queryset,
self.request,
view=self)

def get_paginated_response(self, data):
"""
Return a paginated style `Response` object for the given output data.
"""
assert self.paginator is not None
return self.paginator.get_paginated_response(data)


class CreateService(mixins.CreateModelMixin,
GenericService):
Expand Down
9 changes: 7 additions & 2 deletions django_grpc_framework/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,14 @@ def List(self, request, context):
This is a server streaming RPC.
"""
queryset = self.filter_queryset(self.get_queryset())

page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)

serializer = self.get_serializer(queryset, many=True)
for message in serializer.message:
yield message
return serializer.message


class RetrieveModelMixin:
Expand Down
80 changes: 80 additions & 0 deletions django_grpc_framework/pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from collections import OrderedDict

from django.core.paginator import InvalidPage

from rest_framework.exceptions import NotFound
from rest_framework.pagination import _positive_int # noqa: WPS450
from rest_framework.pagination import PageNumberPagination as BasePageNumberPagination

from django_grpc_framework.settings import grpc_settings
from django_grpc_framework.protobuf.json_format import parse_dict


class PageNumberPagination(BasePageNumberPagination):
"""Pagination class for service."""

# The default page size.
# Defaults to `None`, meaning pagination is disabled.
page_size = grpc_settings.PAGE_SIZE

# Client can control the page size using this query parameter.
# Default is 'None'. Set to eg 'page_size' to enable usage.
page_size_query_param = None
max_page_size = None
proto_class = None

def paginate_queryset(self, queryset, request, view=None):
"""Paginate queryset or raise 'NotFound' on receiving invalid page number."""
self.page_size = self.get_page_size(request) # noqa: WPS601
if not self.page_size:
raise Exception('page_size is not defined.')

paginator = self.django_paginator_class(queryset, self.page_size)
page_number = getattr(request, self.page_query_param, 1)

if not page_number:
page_number = 1

if page_number in self.last_page_strings:
page_number = paginator.num_pages

try:
self.page = paginator.page(page_number)
except InvalidPage as exc:
msg = self.invalid_page_message.format(
page_number=page_number,
message=str(exc),
)
raise NotFound(msg)

return list(self.page)

def get_page_size(self, request):
"""Get and valiate page_size."""
if self.page_size_query_param:
try:
return _positive_int(
getattr(request, self.page_size_query_param),
strict=True,
cutoff=self.max_page_size,
)
except (AttributeError, ValueError):
return self.page_size

return self.page_size

def get_paginated_response(self, data): # noqa: WPS110
"""Return a paginated style `OrderedDict` object for the given output data."""
assert self.proto_class is not None, (
"'%s' should either include a `proto_class` attribute, "
"or override the `get_paginated_response()` method." %
self.__class__.__name__)

response = OrderedDict([
('count', self.page.paginator.count),
('page_size', self.page_size),
('total_pages', self.page.paginator.num_pages),
('results', data),
])
kwargs = {'ignore_unknown_fields': True}
return parse_dict(response, self.proto_class(), **kwargs)
79 changes: 75 additions & 4 deletions django_grpc_framework/services.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,55 @@
from functools import update_wrapper

import grpc
from django.db.models.query import QuerySet
from django import db
from django.db.models.query import QuerySet
from django.http import Http404
from rest_framework import exceptions

from django_grpc_framework.settings import grpc_settings


def find_unique_value_error(exc_detail):
"""Find unique value error in exception details."""
for field, errors in exc_detail.items(): # noqa: B007
for error in errors:
if error.code == 'unique':
return error

return None


def parse_validation_error(exc, context):
"""If code == `unique` return grpc.StatusCode.ALREADY_EXISTS."""
if isinstance(exc.detail, dict):
error = find_unique_value_error(exc.detail)
if error:
context.abort(grpc.StatusCode.ALREADY_EXISTS, error)
return

context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(exc))


def exception_handler(exc: Exception, context) -> None: # noqa: WPS231
"""
Returns the response that should be used for any given exception.

Any unhandled exceptions will return grpc.StatusCode.INTERNAL: Internal error.
"""
if isinstance(exc, (Http404, exceptions.NotFound)): # noqa: WPS223
context.abort(grpc.StatusCode.NOT_FOUND, str(exc))
return
elif isinstance(exc, exceptions.ValidationError):
parse_validation_error(exc, context)
return

raise exc


class Service:

settings = grpc_settings

def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
Expand All @@ -32,6 +76,7 @@ def force_evaluation():
'as the result will be cached and reused between requests.'
' Use `.all()` or call `.get_queryset()` instead.'
)

cls.queryset._fetch_all = force_evaluation

class Servicer:
Expand All @@ -42,20 +87,46 @@ def __getattr__(self, action):
def handler(request, context):
# db connection state managed similarly to the wsgi handler
db.reset_queries()
db.close_old_connections()
try:
self = cls(**initkwargs)
self.request = request
self.context = context
self.action = action
self.context = context
self.request = request
return getattr(self, action)(request, context)
except Exception as exc:
self.handle_exception(exc)
finally:
db.close_old_connections()

update_wrapper(handler, getattr(cls, action))
return handler

update_wrapper(Servicer, cls, updated=())
return Servicer()

def handle_exception(self, exc):
"""
Handle any exception that occurs, by returning an appropriate response,
or re-raising the error.
"""
exception_handler = self.get_exception_handler()

context = self.get_exception_handler_context()
exception_handler(exc, context)

def get_exception_handler_context(self):
"""
Returns a dict that is passed through to EXCEPTION_HANDLER,
as the `context` argument.
"""
return getattr(self, 'context', None)

def get_exception_handler(self):
"""
Returns the exception handler that this view uses.
"""
return self.settings.EXCEPTION_HANDLER


def not_implemented(request, context):
"""Method not implemented"""
Expand Down
8 changes: 8 additions & 0 deletions django_grpc_framework/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

GRPC_FRAMEWORK = {
'ROOT_HANDLERS_HOOK': 'path.to.my.custom_grpc_handlers',
'EXCEPTION_HANDLER': 'django_grpc_framework.services.exception_handler',
}

This module provides the `grpc_setting` object, that is used to access
Expand All @@ -21,13 +22,20 @@

# gRPC server configuration
'SERVER_INTERCEPTORS': None,

# Exception handling
'EXCEPTION_HANDLER': None,

# Pagination
'PAGE_SIZE': None,
}


# List of settings that may be in string import notation.
IMPORT_STRINGS = [
'ROOT_HANDLERS_HOOK',
'SERVER_INTERCEPTORS',
'EXCEPTION_HANDLER',
]


Expand Down
6 changes: 3 additions & 3 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
# -- Project information -----------------------------------------------------

project = 'django-grpc-framework'
copyright = '2020, Shipeng Feng'
author = 'Shipeng Feng'
copyright = '2021, Algori.io'
author = 'Algori.io'

# The full version, including alpha/beta/rc tags
import pkg_resources
Expand Down Expand Up @@ -61,7 +61,7 @@

html_theme_options = {
'github_button': True,
'github_user': 'fengsp',
'github_user': 'algori-io',
'github_repo': 'django-grpc-framework',
}

Expand Down
3 changes: 3 additions & 0 deletions docs/generics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ The following attributes control the basic service behavior:
- ``serializer_class`` - The serializer class that should be used for validating
and deserializing input, and for serializing output. You must either set this
attribute, or override the ``get_serializer_class()`` method.
- ``pagination_class`` - The pagination class that should be used for pagination
style, although you might want to vary individual aspects of the pagination,
such as default or maximum page size.
- ``lookup_field`` - The model field that should be used to for performing object
lookup of individual model instances. Defaults to primary key field name.
- ``lookup_request_field`` - The request field that should be used for object
Expand Down
Loading