The SOP for the backend programming with django-restframework

備註

** 聲明 **

本文件的閱讀對象為敝司業主、潛在業主、熱忱的未來應聘者及任何對敝司抱有興趣者,目的除宣揚敝司管理制度及經營理念外,也是作為敝司員工的程式設計準則之一。

在 django-restframework 的框架下,每個 Api Endpoint 的建立,應利用以下幾個制式的類別組合而成:

  • In authentication.py, put Classes inherited from rest_framework.authentication.*Authentication, ex: rest_framework.authentication.SessionAuthentication

  • In filters.py, put Classes inherited from rest_framework_filters.filters.FilterSet

  • In permissions.py, put Classes inherited from rest_framework.*permissions, ex: rest_framework.permissions.BasePermission

  • In renderers.py, put Classes inherited from rest_framework.renderers.BrowsableAPIRenderer or rest_pandas.renderers.PandasExcelRenderer or rest_pandas.renderers.PandasCSVRenderer

  • In responses.py, put Classes inherited from rest_framework.response.Response

  • In serializers.py, put Classes inherited from rest_framework.serializers.*Serializer, ex: rest_framework.serializers.ModelSerializer

  • In urls.py, put Classes inherited from rest_framework.routers.APIRootView and rest_framework.routers.DefaultRouter instances

  • In views.py, put Classes inherited from rest_framework.viewsets.ModelViewSet or rest_pandas.views.PandasViewSet

最終得到 https://example.domain.name/whaterver-app/whatever-module/whatever-tag/whatever-version/whatever-model/ 的 endpoints 網址。

每一個 Endpoint ,慣例上,應該要對應一個 Model(ex: taiwan_einvoice.views.TurnkeyServiceModelViewSet),或是某條件下被 filter 過的 object set(ex: taiwan_einvoice.views.TurnkeyServiceGroupModelViewSet)。

Browsable Api

具備 Browsable Api 功能 是 django-restframework 優於 django-tastypie 的最大特點,第二特點才是「使用人數 遠高 於後者」。

以下是最精簡建構一個 Browsable Api 所需的相關程式碼:

<!-- api.html -->
{% extends "rest_framework/base.html" %}{% load i18n %}
{% block title %}{% trans "Your Brand" %}{% endblock %}
{% block branding %}
    <a class='navbar-brand' rel="nofollow" href='/'>
        {% trans "Your Named Api Services" %}
    </a>
{% endblock %}
# views.py
from django.conf import settings
from rest_framework.renderers import BrowsableAPIRenderer
from rest_framework.permissions import IsAuthenticated
from my.models import MyModel
from my.filters import MyFilter
from my.serializers import MySerializer
from my.permissions import MyPermission
class MyBrowsableAPIRenderer(BrowsableAPIRenderer):
    template = "some-where/api.html"
class MyModelModelViewSet(viewsets.ModelViewSet):
    permission_classes = (IsAuthenticated, MyPermission)
    queryset = MyModel.objects.all()
    filter_class = MyFilter
    serializer_class = MySerializer
    renderer_classes = (JSONRenderer, MyBrowsableAPIRenderer,
                        ) if settings.DEBUG else (JSONRenderer, )
    http_method_names = ('get', )

# urls.py
from django.urls import include, re_path
from rest_framework import routers
from my.views import MyModelModelViewSet
class MyRouter(routers.DefaultRouter):
    pass
router = MyRouter()
router.register(r'mymodel', views.MyModelModelViewSet, basename="mymodel")
version = 'v1'
urlpatterns = [
    re_path(r'^api/{}/'.format(version),
            include((router.urls, "myapi"),
                    namespace="myapi")),
]

#INFO Endpoint of MyModel: https://example.domain.name/whaterver-app/my/api/v1/mymodel/

在開發階段, 必定要使用 RESTFramework 的 Browsable Api 頁面進行自身 Api 的測試 ,而無須依賴外部 Api 工具,如: POSTMAN 。外部 Api 工具可以作雙重驗證使用,但 RESTFramework 的 Browsable Api 是必備的。

待完成開發階段,發佈至「正式網站」時,再依「服務提供性質」,適當地移除或是保留 Browsable Api 頁面,例如採用下列語法:

class MyModelModelViewSet(viewsets.ModelViewSet):
    renderer_classes = [JSONRenderer, MyBrowsableAPIRenderer,
                       ] if settings.DEBUG else [JSONRenderer, ]
    def initial(self, request, *args, **kwargs):
        if request.user.is_superuser and MyBrowsableAPIRenderer not in self.renderer_classes:
            self.renderer_classes += [MyBrowsableAPIRenderer, ]
        super().initial(request, *args, **kwargs)

Avoid to expose information too much

在 Browsable Api 的資訊揭露上,主要分三個部份討論。

Display the field of every instance

在瀏覽 https://example.domain.name/whatever-app/whatever-module/api/v1/whatever-model/20/ 所輸出的 json/xml/plaintext/… ,其欄位內容要符合「連線使用者身份」的權限。

這部份要注意的是 Serializer Class 的設定。範例如下:

class CreateTimeOnlyForCreatorField(serializers.ReadOnlyField):
    def get_attribute(self, instance):
        if instance.creator == self.context['request'].user:
            return super(CreateTimeOnlyForCreatorField, self).get_attribute(instance)
        return None
class MySerializer(serializers.ModelSerializer):
    create_time = CreateTimeOnlyForCreatorField()
    resource_uri = serializers.HyperlinkedIdentityField(
        view_name="my_api_root:my-detail",
        lookup_field='pk')
    class Meta:
        model = MyModel
        fields = '__all__' if settings.DEBUG else ('resource_uri', 'create_time', 'id')

POST Form in API format

https://example.domain.name/whatever-app/whatever-module/api/v1/whatever-model/?format=api 頁面上,所存在的 POST Form ,就某些「下拉式選項所出現的 Option 」,其 Option Value 要符合「連線使用者身份」權限所能觀看的值。

這部份要注意的是 Serializer Class 的設定。範例如下:

class SomeRelatedField(serializers.PrimaryKeyRelatedField):
    def get_queryset(self):
        request = self.context.get('request', None)
        if request and request.user.is_superuser:
            return SomeModel.objects.all().order_by('id')
        elif 'api' == request.GET.get('format', '') or 'api' == request.POST.get('format', ''):
            return SomeModel.objects.none()
        else:
            return get_objects_for_user(request.user if request else AnonymousUser,
                                        ("module_name.view_model_permision",
                                        "module_name.edit_model_permision",
                                        "module_name.delete_model_permision",
                                        ),
                                        any_perm=True,
                                        with_superuser=True,
                                        accept_global_perms=False,
                                        ).order_by('id')
class MySerializer(serializers.ModelSerializer):
    resource_uri = serializers.HyperlinkedIdentityField(
        view_name="my_api_root:my-detail",
        lookup_field='pk')
    some = SomeRelatedField(required=True, allow_null=False)
    class Meta:
        model = MyModel
        fields = '__all__' if settings.DEBUG else ('resource_uri', 'some', 'id')

Filter Form in API format

在瀏覽 https://example.domain.name/whatever-app/whatever-module/api/v1/whatever-model/ 所提供的 Filter Form ,就某些「下拉式選項所出現的 Option 」,其 Option Value 要符合「連線使用者身份」權限所能觀看的值。

這部份要注意的是 Filter/ViewSet Class 的設定。範例如下:

class PopedomFilter(rest_framework_filters.FilterSet):
    class Meta:
        model = Popedom
        fields = {
            'name': ('icontains', ),
        }
def popedom_queryset_by_request_user(request):
    if request.user.is_superuser or request.user.is_staff:
        return Popedom.objects.all().order_by('name')
    else:
        return get_objects_for_user(request.user,
                                    ("collection.view_popedom",
                                    "collection.own_popedom",
                                    "collection.update_popedom",
                                    "collection.create_device_box_under_this_popedom",
                                    ),
                                    any_perm=True,
                                    with_superuser=True,
                                    accept_global_perms=False,
                                    ).order_by('id')
class MyFilter(rest_framework_filters.FilterSet):
    popedom = rest_framework_filters.RelatedFilter(PopedomFilter,
                                                   label=_('Popedom'),
                                                   field_name="popedom",
                                                   queryset=popedom_queryset_by_request_user)
    class Meta:
        model = MyModel
        fields = {
            'name': ('icontains', ),
        }
class MyModelViewSet(viewsets.ModelViewSet):
    queryset = MyModel.objects.all()
    filter_class = MyFilter

Permission Control

利用 *ViewSet 撰寫 api 時,permission_classes 裡面每個 permission 預設都是 and 的關係,必須要全部通過才會執行相關 action ,若要使用 OR 關係時可以引用 ho600_lib.permissions.Or。

*ViewSet 除了加上應該要有的 permission_classes 之外,也應該要在 *.ViewSet.get_queryset 函式裡面限制可以暴露給該使用者的資料,做另一層防護。

權限控制以 django 內建權限架構及 django-guardian 為基礎,在判斷權限時,以 request.user 為出發點,來判斷他/她能不能 CRUD 某個物件,並儘量不要摻雜其他判斷條件。

例如: 某人要刪除某一任務,而功能需求又限制只能刪除創建時間超過 3 年以上的任務,則「權限判斷」應僅止於在 *ViewSet.permission_classes 及 *ViewSet.get_queryset 處理,前者處理「某人有沒有某個 permission_codename 或某人在某個物件上有沒有某個 permission_codename」,後者是把「某人具備某個 permission_codename 的物件全部撈出來」,兩者要同時存在,且不可互相抵觸。而「只有創建時間超過 3 年以上的任務可刪除」的條件,必須置於 *ViewSet.perform_destroy 函式之中。以下為範例程式:

class IsSuperuserOrStaff(BasePermission):
    def has_permission(self, request, view):
        res = False
        res = (request.user.is_authenticated()
               and (request.user.is_superuser
                    or request.user.is_staff))
        return res
    def has_object_permission(self, request, view, obj):
        res = False
        res = (request.user.is_authenticated()
               and (request.user.is_superuser
                    or request.user.is_staff))
        return res
class DealWithTicketPermission(BasePermission):
    ANY_PERM = True
    WITH_SUPERUSER = False
    ACCEPT_GLOBAL_PERMS = False
    GET_USE_GROUP_PERMS = True
    ACTION_PERMISSION_MAPPING = {
        "create": ("ticket.create_ticket", ),
        "list": ("ticket.view_ticket", "ticket.own_ticket",
                 "ticket.update_ticket", ),
        "retrieve": ("ticket.view_ticket", "ticket.own_ticket",
                     "ticket.update_ticket", ),
        "partial_update": ("ticket.own_ticket",
                           "ticket.update_ticket", ),
        "update": ("ticket.own_ticket",
                   "ticket.update_ticket", ),
        "destroy": ("ticket.own_ticket", ),
    }
    def has_permission(self, request, view):
        res = False
        if 'create' == view.action:
            res = request.user.has_perm(self.ACTION_PERMISSION_MAPPING[view.action])
        elif view.action in self.ACTION_PERMISSION_MAPPING:
            res = True
        return res
    def has_object_permission(self, request, view, obj):
        qs = Q()
        for _i in self.ACTION_PERMISSION_MAPPING.get(view.action, []):
            app_label, codename = _i.split('.')
            qs = qs | Q(content_type__app_label=app_label, codename=codename)
        res = False
        if view.action in self.ACTION_PERMISSION_MAPPING:
            if get_user_perms(request.user, obj
                             ).filter(qs).exists():
                res = True
        return res
class TicketModelViewSet(viewsets.ModelViewSet):
    permission_classes = (Or(IsSuperuserOrStaff, DealWithTicketPermission), )
    queryset = Ticket.objects.all()
    filter_class = TicketFilter
    serializer_class = TicketSerializer
    renderer_classes = (JSONRenderer, BrowsableAPIRenderer, ) if settings.DEBUG else (JSONRenderer, )
    http_method_names = ('get', 'delete', )
    def get_queryset(self):
        return get_objects_for_user(self.request.user,
                                    DealWithTicketPermission.ACTION_PERMISSION_MAPPING[self.action],
                                    any_perm=DealWithTicketPermission.ANY_PERM,
                                    use_groups=DealWithTicketPermission.GET_USE_GROUP_PERMS,
                                    with_superuser=DealWithTicketPermission.WITH_SUPERUSER
                                                   or DealWithTicketPermission.ACCEPT_GLOBAL_PERMS,
                                    accept_global_perms=DealWithTicketPermission.ACCEPT_GLOBAL_PERMS,
                                   ).order_by('id')
    def perform_destroy(self, obj):
        if obj.is_expired:
            return super().perform_destroy(obj)
        else:
            raise SomeException('...')

# models.py
class Ticket(models.Model):
    ...
    @property
    def is_expired(self):
        if (datetime.datetime.utcnow() - self.create_time) > datetime.datetime.timedelta(years=3):
            return True
        else:
            return False

將判斷「任務是否過期」的條件置入 Model 中,這是原有 Django 開發所制定的規範,與 RESTful Api 無關。也就是說,在整個系統上,可能有一堆地方都要去判斷 Ticket instance 是否過期,這個 「> 3年」 的判斷式只應該存在於一處,而最佳的地方就是在 Model 內定義。

備註

To ho600 employees ,

XXXModule.permissions 中通常會有一個 DealwithXXXProjectRelatedPermission or DealwithXXXShopRelatedPermission 之類的 class ,主要原因在於「權限設計上,慣於把使用者操作 XXXModule 的權限先全歸類到 Project or Shop class」,例如: 「project.view_ticket」, 「shop.view_order」 的 codename 是設定在 Project/Shop class 內,而不使用 「ticket.view_ticket」, 「order.view_order」,且 ticket objects 必定歸屬於某個 project object 、 order objects 必定歸屬於某個 shop object 。

在 taiwan_einvoice module 中,雖然權限 codename 是定義在 TurnkeyService Model 之下,就並未創建 DealwithTaiwanTurnkeyServiceRelatedPermission 的父權限類別給其他 EInvoice, UploadBatch permission classes 繼承。