diff --git a/CHANGES b/CHANGES index a12af7d..3dc08ff 100644 --- a/CHANGES +++ b/CHANGES @@ -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 ----------- diff --git a/README.rst b/README.rst index 7c0802d..0e6b0ad 100644 --- a/README.rst +++ b/README.rst @@ -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 @@ -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 diff --git a/TODO b/TODO index 84bd3a0..1edeb7c 100644 --- a/TODO +++ b/TODO @@ -2,9 +2,9 @@ TODO ==== - relationships -- error handling - add BulkCreate\BulkDestroy\BulkUpdate with stream-unary mode ? - authentication - logging - add testcases for project -- filter support ? \ No newline at end of file +- filter support ? +- google.protobuf.FieldMask support diff --git a/django_grpc_framework/__init__.py b/django_grpc_framework/__init__.py index b650ceb..5a6f84c 100644 --- a/django_grpc_framework/__init__.py +++ b/django_grpc_framework/__init__.py @@ -1 +1 @@ -__version__ = '0.2' +__version__ = '0.5' diff --git a/django_grpc_framework/generics.py b/django_grpc_framework/generics.py index f3ed527..f6f93b1 100644 --- a/django_grpc_framework/generics.py +++ b/django_grpc_framework/generics.py @@ -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): @@ -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. @@ -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): diff --git a/django_grpc_framework/mixins.py b/django_grpc_framework/mixins.py index a8bed0d..14eddb3 100644 --- a/django_grpc_framework/mixins.py +++ b/django_grpc_framework/mixins.py @@ -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: diff --git a/django_grpc_framework/pagination.py b/django_grpc_framework/pagination.py new file mode 100644 index 0000000..f4ad067 --- /dev/null +++ b/django_grpc_framework/pagination.py @@ -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) diff --git a/django_grpc_framework/services.py b/django_grpc_framework/services.py index e880c16..6563883 100644 --- a/django_grpc_framework/services.py +++ b/django_grpc_framework/services.py @@ -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) @@ -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: @@ -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""" diff --git a/django_grpc_framework/settings.py b/django_grpc_framework/settings.py index 2f4394e..525906a 100644 --- a/django_grpc_framework/settings.py +++ b/django_grpc_framework/settings.py @@ -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 @@ -21,6 +22,12 @@ # gRPC server configuration 'SERVER_INTERCEPTORS': None, + + # Exception handling + 'EXCEPTION_HANDLER': None, + + # Pagination + 'PAGE_SIZE': None, } @@ -28,6 +35,7 @@ IMPORT_STRINGS = [ 'ROOT_HANDLERS_HOOK', 'SERVER_INTERCEPTORS', + 'EXCEPTION_HANDLER', ] diff --git a/docs/conf.py b/docs/conf.py index 2f646be..5ba3bf2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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 @@ -61,7 +61,7 @@ html_theme_options = { 'github_button': True, - 'github_user': 'fengsp', + 'github_user': 'algori-io', 'github_repo': 'django-grpc-framework', } diff --git a/docs/generics.rst b/docs/generics.rst index b0c2f9e..4f0fcce 100644 --- a/docs/generics.rst +++ b/docs/generics.rst @@ -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 diff --git a/docs/installation.rst b/docs/installation.rst index 6e15194..a71acd5 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -51,6 +51,6 @@ Development Version Try the latest version:: $ source env/bin/activate - $ git clone https://github.com/fengsp/django-grpc-framework.git + $ git clone https://github.com/algori-io/django-grpc-framework.git $ cd django-grpc-framework $ python setup.py develop diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 6f7569c..0a86c0f 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -123,6 +123,7 @@ Now we'd write some a service, create ``account/services.py``:: """ queryset = User.objects.all().order_by('-date_joined') serializer_class = UserProtoSerializer + pagination_class = PageNumberPagination Register handlers diff --git a/docs/server.rst b/docs/server.rst index a309f6f..bb65d04 100644 --- a/docs/server.rst +++ b/docs/server.rst @@ -56,4 +56,29 @@ in your ``settings.py`` file:: 'path.to.DoSomethingInterceptor', 'path.to.DoAnotherThingInterceptor', ] - } \ No newline at end of file + } + +Exception handler +``````````````````` + +The exception handler must also be configured in your settings, using the +``EXCEPTION_HANDLER`` setting key, for example set the following in your +``settings.py`` file:: + + GRPC_FRAMEWORK = { + ... + 'EXCEPTION_HANDLER': 'django_grpc_framework.services.exception_handler', + } + +Setting the pagination page size +``````````````````` + +If you need to use default List method with pagination, you can do so by setting the + +``PAGE_SIZE`` setting. For example, have something like this +in your ``settings.py`` file:: + + GRPC_FRAMEWORK = { + ... + 'PAGE_SIZE': 10, + } diff --git a/docs/settings.rst b/docs/settings.rst index 3ad98df..3b97ee9 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -9,6 +9,8 @@ file might look like this:: GRPC_FRAMEWORK = { 'ROOT_HANDLERS_HOOK': 'project.urls.grpc_handlers', + 'EXCEPTION_HANDLER': 'django_grpc_framework.services.exception_handler', + 'PAGE_SIZE': 10, } @@ -47,4 +49,10 @@ Configuration values An optional list of ServerInterceptor objects that observe and optionally manipulate the incoming RPCs before handing them over to handlers. - Default: ``None`` \ No newline at end of file + Default: ``None`` + +.. py:data:: EXCEPTION_HANDLER + + The exception handler must also be configured in your settings, using the EXCEPTION_HANDLER setting key. + + Default: ``None`` diff --git a/docs/tutorial/building_services.rst b/docs/tutorial/building_services.rst index 81c4414..d72c2f3 100644 --- a/docs/tutorial/building_services.rst +++ b/docs/tutorial/building_services.rst @@ -125,7 +125,7 @@ and deserializing the post instances into protocol buffer messages. We can do this by declaring serializers, create a file in the ``blog`` directory named ``serializers.py`` and add the following:: - from django_grpc_framework import proto_serializerss + from django_grpc_framework import proto_serializers from blog.models import Post from blog_proto import post_pb2 diff --git a/docs/tutorial/index.rst b/docs/tutorial/index.rst index 79ae85b..e92252c 100644 --- a/docs/tutorial/index.rst +++ b/docs/tutorial/index.rst @@ -8,11 +8,11 @@ In this tutorial, we will create a simple blog rpc server. You can get the source code in `tutorial example`_. .. _tutorial example: - https://github.com/fengsp/django-grpc-framework/tree/master/examples/tutorial + https://github.com/algori-io/django-grpc-framework/tree/master/examples/tutorial .. toctree:: :maxdepth: 2 building_services using_generics - writing_tests \ No newline at end of file + writing_tests diff --git a/setup.py b/setup.py index 49b8e09..8d3a0be 100644 --- a/setup.py +++ b/setup.py @@ -12,9 +12,9 @@ version=version, description='gRPC for Django.', long_description=open('README.rst', 'r', encoding='utf-8').read(), - url='https://github.com/fengsp/django-grpc-framework', - author='Shipeng Feng', - author_email='fsp261@gmail.com', + url='https://github.com/algori-io/django-grpc-framework', + author='Algori.io', + author_email='it@algori.io', packages=find_packages(), install_requires=[], python_requires=">=3.6",