The SOP for backend programming with django-restframework¶
備註
** 聲明 **
本文件的閱讀對象為敝司業主、潛在業主、熱忱的未來應聘者及任何對敝司抱有興趣之活人,目的乃宣揚敝司管理制度及經營理念。敝司員工當以 private-docs/software/sop_in_backend_programming_with_restframework.rst 為執行準則。
在 django-restframework 的框架下,每個 Api Endpoint 的建立,應利用以下幾個制式的類別組合而成:
In permissions.py, put Classes inherited from rest_framework.*permissions
In serializers.py, put Classes inherited from rest_framework.serializers.*Serializer
In filters.py, put Classes inherited from django_filters.rest_framework.*Filter* or rest_framework_filters.*Filter*/…
In renderers.py, put Classes inherited from rest_framework.renderers.*Renderer or rest_pandas.renderers.*Renderer*
In responses.py, put Classes inherited from rest_framework.response.*Response*
In views.py, put Classes inherited from rest_framework.viewsets.*ViewSet* or rest_pandas.views.*ViewSet*/…
In urls.py, put Classes inherited from rest_framework.routers.*APIRootView* and rest_framework.routers.*Router* instances
最終得到 https://example.domain.name/whaterver-app/whatever-module/whatever-tag/whatever-version/whatever-model/ 的 endpoint 網址。
每一個 Endpoint ,慣例上,應該要對應一個 Model ,或是某條件下被 filter 過的 object set 。
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, )
Avoid to expose information too much¶
在 Browsable Api 的資訊揭露上,主要分三個部份討論。
Display 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 for the ViewSet¶
在 https://example.domain.name/whatever-app/whatever-module/api/v1/whatever-model/ 頁面上,所存在的 Post Form ,就某些「下拉式選項所出現的 Option 」,其 Option Value 要符合「連線使用者身份」權限所能觀看的值。
這部份要注意的是 Serializer Class 的設定。範例如下:
class SomeRelatedField(serializers.PrimaryKeyRelatedField):
def get_queryset(self):
request = self.context.get('request', None)
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 form the ViewSet¶
在瀏覽 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_name 或某人在某個物件上有沒有某個 permission_name」,後者是把「某人具備某個 permission_name 的物件全部撈出來」。兩者要同時存在,且不可互相抵觸。而「只有創建時間超過 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):
METHOD_PERMISSION_MAPPING = {
"POST": ("ticket.create_ticket", ),
"GET": ("view_ticket", "own_ticket", "update_ticket", ),
"PATCH": ("own_ticket", "update_ticket", ),
"PUT": ("own_ticket", "update_ticket", ),
"DELETE": ("own_ticket", ),
}
def has_permission(self, request, view):
res = False
if request.method == 'POST':
res = request.user.has_perm(self.METHOD_PERMISSION_MAPPING[request.method])
elif request.method in self.METHOD_PERMISSION_MAPPING:
res = True
return res
def has_object_permission(self, request, view, obj):
res = False
if request.method in self.METHOD_PERMISSION_MAPPING:
if get_user_perms(request.user, obj
).filter(content_type__app_label='ticket',
codename__in=self.METHOD_PERMISSION_MAPPING[request.method]
).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,
("ticket.view_ticket",
"ticket.own_ticket",
"ticket.update_ticket", ),
any_perm=True,
with_superuser=True,
accept_global_perms=False,
).order_by('id')
def perform_destroy(self, obj):
if obj.is_expired:
super(TicketModelViewSet, self).perform_destroy(obj)
else:
raise SomeException('...')
# models.py
class Ticket(models.Model):
...
@property
def is_expired(self):
if (self.create_time - datetime.datetime.utcnow()) > datetime.datetime.timedelta(years=3):
return True
else:
return False
將判斷「任務是否過期」的條件置入 Model 中,這是原有 Django 開發所制定的規範,與 RESTful Api 無關。也就是說,在整個系統上,可能有一堆地方都要去判斷 Ticket instance 是否過期,這個 「> 3年」 的判斷式只應該存在於一處,而最佳的地方就是 Model 內的定義。