Django RESTful Web Service API

Đây là bài tóm tắt cách sử dụng django rest framework để viết REST Web Service API. Trong bài này bạn sẽ học cách quản lý danh sách nhà hàng và các chi nhánh của nhà hàng. API sẽ được sẽ là:

GET /restapp/restaurant # danh sách nhà hàng 
POST /restapp/restaurant # thêm nhà hàng mới 
PATCH /restapp/restaurant # update thông tin nhà hàng 
GET /restapp/branch # danh sách các chi nhánh
GET /restapp/branch?restaurant={id} # danh sách chi nhánh theo id của nhà hàng 
POST /restapp/branch #thêm chi nhánh mới 
PATCH /restapp/branch # update thông tin chi nhánh 

Hai website tài liệu chính được sử dụng cho bài học này là: Django Rest, Django Queryset

Bạn nên sử dụng POSTMAN để test các api mình làm.

Dùng python 3.7

Bạ nên dùng tối thiểu python 3.7. Bài viết này hỗ trợ chủ yếu cho các bạn dùng Linux (Ubuntu…) hay Mac OS X.

Mở Terminal và kiểm tra version của python

python3 --version
# bạn cũng có thể dùng rõ phiên bản:
python3.7 --version

Cài đặt Django, rest framework và module mysqlclient

Dùng lệnh sau để cài đặt django qua Terminal:

python3.7 -m pip install Django

Sau đó cài mysqlclient để kết nối django với mysql database. Tiếp đó, cài đặt rest framework:

#Ubuntu 
sudo python3.7 -m pip install mysql-connector
sudo apt-get install python3.7-dev libmysqlclient-dev
python3.7 -m pip install mysqlclient
python3.7 -m pip install djangorestframework

Tạo dự án

Khởi tạo dự án :

django-admin startproject djangoresttraining 

Tạo cơ sở dữ liệu trên mysql mang tên “djangoresttraining” và import cấu trúc sau:

CREATE TABLE `restapp_branches` (
  `id` int(11) NOT NULL,
  `branch_name` varchar(255) NOT NULL,
  `restaurant_id` int(11) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `restapp_restaurants` (
  `id` int(11) NOT NULL,
  `name` varchar(255) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

ALTER TABLE `restapp_branches`
  ADD PRIMARY KEY (`id`),
  ADD KEY `restaurant_id` (`restaurant_id`);

ALTER TABLE `restapp_restaurants`
  ADD PRIMARY KEY (`id`);

ALTER TABLE `restapp_branches`
  MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;

ALTER TABLE `restapp_restaurants`
  MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;

ALTER TABLE `restapp_branches`
  ADD CONSTRAINT `restapp_branches_ibfk_1` FOREIGN KEY (`restaurant_id`) REFERENCES `restapp_restaurants` (`id`);

Tiếp đó, bạn config để django có thể kết nối vào mysql database của bạn. Update file djangoresttraining/settings.py:

# djangoresttraining/settings.py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'djangoresttraining',
        'USER': 'root',
        'PASSWORD': 'root',
        'HOST': 'localhost', # hoặc 127.0.0.1 hoặc domain/ip của server của bạn 
        'PORT': '3636', # hoặc port của bạn 
    }
}

Sau đó vào thư mục của dự án và tao restapp nơi sẽ chứa REST API code của bài học này. Một project của django có thể có nhiều app khác nhau (như nhiều module khác nhau)

cd djangoresttraining
# them app vao du an
python3.7 manage.py startapp restapp 
# start thử server. 
python3.7 manage.py runserver
# Nếu thành công, bạn sẽ thấy thông điệp như sau:
Django version 3.1, using settings 'driver_app.settings'
Starting development server at http://127.0.0.1:2000/
Quit the server with CONTROL-C.

Thêm restapp vào dự án:

#djangoresttraining/settings.py:
INSTALLED_APPS = [...
    ...,
    'rest_framework',
    'restapp',
] 

Update djangoresttraining/urls.py:

from django.urls import path, include, re_path
...
urlpatterns = [
    path('admin/', admin.site.urls),
    path('restapp/', include('restapp.urls')), # sử dụng file restapp/urls.py khi path được gọi bắt đầu bởi "restapp/"
]

Tạo restapp/urls.py

from django.urls import path
from django.conf.urls import url
from . import views

urlpatterns = [
    path('', views.RestaurantView.as_view(), name='restaurant')
]

Tạo model trong restapp/models.py

from django.db import models

# Tên class cần giống với tên bảng trong cơ sở dữ liệu
class Restaurants(models.Model):
    name = models.CharField(max_length=255) # giống tên cột

class Branches(models.Model):
    branch_name = models.CharField(max_length=255) # giống tên cột 
    restaurant = models.ForeignKey(Restaurants, on_delete=models.CASCADE) # bỏ "_id" khỏi tên cột nếu là foreign key. Django sẽ tự thêm "_id" khi truy vấn.
    

Tạo restapp/serializers.py

File serializers giúp convert data từ model thành json và ngược lại. Serializers cũng có nhiều tiện ích cho việc save/update data:

from rest_framework import serializers
from . import models

# 1 serializer class duy nhất cho restaurant
class RestaurantsSerializer(serializers.ModelSerializer):
    class Meta:
        model = models.Restaurants
        fields = '__all__'

# Riêng đối với branch, chúng ta sẽ có 2 serializer khác nhau
# cho việc view vì sẽ có thêm thông tin restaurant kèm theo (chỉ để read_only)
class BranchesViewSerializer(serializers.ModelSerializer):
    restaurant = RestaurantsSerializer(read_only=True)
    class Meta:
        model = models.Branches
        fields = '__all__'

# Serializer cho branch phục vụ cho các thao tác create, update, delete 
class BranchesSerializer(serializers.ModelSerializer):
    class Meta:
        model = models.Branches
        fields = '__all__'

Tạo views cho API của restaurant

Views là cách mà django rest sẽ trả về kết quả json cho các api. Trong view, chúng ta cũng sẽ dùng class Response của django rest để trả về kết quả theo format bao gồm data và status code.

#restapp/views.py 
from django.shortcuts import render
from . import models
from . import serializers
from rest_framework.response import Response

# Create your views here.
from rest_framework import generics, permissions, status


class RestaurantView(generics.GenericAPIView):
    # nhớ cần khai báo: permission_classes, queryset và serializer_class 
    # để đầy đủ điều kiện triển khai của GenericAPIView 
    permission_classes = (permissions.AllowAny,)
    queryset = models.Restaurants.objects.all()
    serializer_class = serializers.RestaurantsSerializer

    # khi gọi api bằng method GET để lấy record, django sẽ gọi hàm get() dưới đây để xử lý 
    def get(self, request):
        myqueryset = models.Restaurants.objects.all()
        # chú ý để many=True vì kết quả sẽ là dạng list các model 
        list_serializer = serializers.RestaurantsSerializer(myqueryset, many=True) 
        # sử dụng Response class của django rest 
        return Response(list_serializer.data, status=status.HTTP_200_OK)

    # khi gọi api bằng method POST để tạo record mới, django sẽ gọi hàm post() dưới đây để xử lý 
    def post(self, request, *arg, **kwargs):
        try:
            # lưu ý dùng partial=True để chuỗi json đầu vào có thể có 1 
            #     hoặc nhiều field của restaurants (nếu có). 
            # Nếu không có partial=True, 
            #     django rest sẽ đòi hỏi phải truyền vào tất cả các field của bảng restaurant (ngoại trừ id)
            obj_serializer = serializers.RestaurantsSerializer(data=request.data, partial=True)

            # kiểm tra xem json có hợp lệ không?
            if obj_serializer.is_valid():
                obj_serializer.save() # save dữ liệu
                return Response(obj_serializer.data, status=status.HTTP_201_CREATED)
        except Exception as ex:
            return Response(ex.args, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

Dùng postman để kiểm tra kết quả của API:

Tạo View cho API của Branch

Tương tự như restaurant, bạn có thể tạo api để lấy danh sách các branch như sau:

# cùng trong restapp/views.py 
class BranchView(generics.GenericAPIView):
    permission_classes = (permissions.AllowAny,)
    queryset = models.Branches.objects.all()
    serializer_class = serializers.BranchesViewSerializer

    def get(self, request):
        myqueryset = models.Branches.objects.all()
        # Chú ý dùng BranchesViewSerializer để có được data của toàn bộ record restaurant (vì BranchesViewSerializer có khai báo quan hệ với forgeinkey restaurant_id)
        list_serializer = serializers.BranchesViewSerializer(myqueryset, many=True)
        return Response(list_serializer.data, status=status.HTTP_200_OK)

Điểm chú ý đặc biệt ở trên là bạn cần dùng BranhcesViewSerializer chứ không phải BranchesSerializer. Vì BranchesViewSerializer đã thiết lập thêm quan hệ với restaurant, và sẽ cho phép kết quả json trả về bao gồm cả thông tin của bảng restaurant. BranchesViewSerializer chỉ dùng cho các api liên quan đến xem dữ liêu (không update/create/delete).

Tiếp theo, update urls.py để tách api của restaurant và branch thành 2 nhóm endpoints (paths) khác nhau, không gây ra sự nhầm lẫn.

# restapp/urls.py

urlpatterns = [
    path('restaurant', views.RestaurantView.as_view(), name='restaurant'),
    path('branch', views.BranchView.as_view(), name='branch')
]

API tạo branch mới

Để thêm api tạo mới 1 branch, chúng ta viết thêm hàm post trong view của branch.

Tuy nhiên serializer sẽ là BranchSerializer chứ không dùng BranchViewSerializer:

class BranchView(generics.GenericAPIView):
    ...

    def get(self, request):
        ...

    def post(self, request):
        try:
            obj_serializer = serializers.BranchesSerializer(data=request.data, partial=True)
            if obj_serializer.is_valid():
                obj_serializer.save()
                return Response(obj_serializer.data, status=status.HTTP_201_CREATED)
        except Exception as ex:
            return Response(ex.args, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

API để update thông tin của branch có sẵn

Chúng ta sẽ dùng method PATCH để thực hiện việc update thông tin có sẵn của branch. Triển khai hàmg patch() dưới đây của GenericAPIView để có được chức năng này:

# Chúng ta sẽ dùng exception này để xử lý trường hợp 
#     record không tồn tại trong cơ sở dữ liệu
from django.core.exceptions import ObjectDoesNotExist

class BranchView(generics.GenericAPIView):
    ...
    ...
    # API sẽ được gọi kèm theo param "?branch={id của branch}"
    # PATCH /branch/?id=2
    def patch(self, request, *args, **kwargs):
        # lấy param từ URL path 
        branch_id = request.query_params.get("id")

        try:
            # lấy record từ cơ sở dữ liệu. "pk" có nghĩa là primary key, ở đây tương đương cột "id"
            model = models.Branches.objects.get(pk=branch_id)
            # chú ý sự khác biệt khi dùng BranchesSerializer ở đây so với api GET 
            # có thêm biến đầu tiên truyền vào chính là model đại diện cho record được tìm thấy.
            obj_serializer = serializers.BranchesSerializer(model, data=request.data, partial=True)
            if obj_serializer.is_valid():
                obj_serializer.save()
                return Response(obj_serializer.data, status=status.HTTP_200_OK)
        except ObjectDoesNotExist as e:
            # nếu không có record tồn tại, trả về exception ObjectDoesNotExist:
            return Response(ex.args, status=status.HTTP_404_NOT_FOUND)

        return Response({"error": "Something went wrong"}, status.HTTP_400_BAD_REQUEST)

Phân trang với Pagination

Django Rest có đi kèm sẵn cấu hình để giúp việc phân trang kết quả trả về trong API json. Dưới đây là các bước cần làm để api của bạn có thể sử dụng chức năng này:

# import class PageNumberPagination để phân trang
from rest_framework.pagination import PageNumberPagination
...
class RestaurantView(generics.GenericAPIView):
    ...
    # khai báo class sử dụng cho việc phân trang
    pagination_class = PageNumberPagination 

    # khai báo số record trong 1 trang
    page_size = 10  

    # số record tối đa
    max_page_size = 20  

    # tên query param có thể dùng để thay đổi số record của 1 trang 
    page_size_query_param = 'page_size' 

    # Update code của hàmg xử lý phương thức get 
    def get(self, request):
        # khai báo kích cỡ của 1 trang:
        self.pagination_class.page_size = self.page_size

        # tạo queryset để lấy tất cả các nhà hàng trong csdl 
        myqueryset = models.Restaurants.objects.all()
        # dùng hàm paginate_queryset của PageNumberPagination để lấy theo trang
        page = self.paginate_queryset(myqueryset)

        # Nếu lấy thành công theo trang, 
        # kết quả trả về "result" cũng cần format theo dạng phân trang 
        #   bằng cách sử dụng hàm get_paginated_response 
        if page is not None:
            serializer = self.serializer_class(page, many=True)
            result = self.get_paginated_response(serializer.data)
        else:
            result = self.serializer_class(myqueryset, many=True)

        return Response(result.data, status=status.HTTP_200_OK)

    

Truy vấn cơ sở dữ liệu với filter() và Q

Trong các ví dụ trên, chúng ta đã thấy cách để truy vấn cơ sở dữ liệu sử dụng queryset của django:

myqueryset = models.Restaurants.objects.all()

Tương tự vậy, để truy vấn có điều kiện, chúng ta có thể dùng như sau:

# name được truyền vào qua url: /restapp/restaurant?name=KFC
requested_name = request.query_params.get("name")
myqueryset = models.Restaurants.objects.filter(name=requested_name)

Chúng ta cũng có thể dùng từ khoá “LIKE”:

myqueryset = models.Restaurants.objects.filter(name__contains=requested_name)

Để biết được tất cả các từ khoá hay so sánh mà chúng ta có thể sử dụng, bạn hãy tham khảo tại: https://docs.djangoproject.com/en/3.0/ref/models/querysets/#id4

Kết hợp điều kiện với “AND”

Giả định bạn có thêm 2 trường mới cho bảng restaurants:

  1. is_active TINY_INT(1): với giá trị = 1 tương đương với cửa hàng còn hoạt động, = 0 nếu cửa hàng đã đóng cửa
  2. rating FLOAT: là đánh giá chất lượng, thang điểm từ 0.0 tới 5.
ALTER TABLE `restapp_restaurants`  ADD `is_active` TINYINT(1) NULL DEFAULT '1' COMMENT '1: active, 0: inactive'  AFTER `name`,  ADD `rating` FLOAT NULL COMMENT '0.0->5'  AFTER `is_active`;

Cập nhật models.py:

class Restaurants(models.Model):
    name = models.CharField(max_length=255)
    is_active = models.IntegerField(null=True) # thêm mới 
    rating = models.FloatField(null=True) # thêm mới 

Truy vấn các restaurant có is_active = 1, và rating >= 3.5

myqueryset = models.Restaurants.objects.filter(is_active=1, rating__gte=3.5)

Tuy nhiên, nếu chúng ta chỉ muốn kiểm tra rating nếu rating là 1 điều kiện khi gọi API, vd khi chúng ta gọi “/restapp/restaurant?rating=3”. Nếu chỉ gọi “/restapp/restaurant” thì không kiểm tra điều kiện rating. Với trường hợp này, chúng ta sử dụng “Q” của django:

    from django.db.models import Q

    def get(self, request):
        self.pagination_class.page_size = self.page_size  # IMPORTANT LINE
        min_rating = request.query_params.get("rating", 0)
        is_active = request.query_params.get("is_active", 1)
        
        myq = Q(is_active=is_active)
        if int(min_rating) > 0:
            # dùng "&" để kết hợp 
            myq = myq & Q(rating__gte=min_rating)
        myqueryset = models.Restaurants.objects.filter(myq)

Truy vấn với OR

Với OR, bạn cũng dùng “Q” và kết hợp với operand “|”:

myq = Q(is_active=is_active) | Q(rating__gte=min_rating)

Bạn có thể tra cứu tại đây để tạo các câu lệnh phức tạp với Q: https://docs.djangoproject.com/en/3.1/topics/db/queries/#complex-lookups-with-q

Truy vấn với excludes

Tìm hiểu thêm về exclude: https://docs.djangoproject.com/en/3.0/ref/models/querysets/#exclude