【基本編】Django REST framework (DRF)チュートリアル-Slack風サービスを開発する-

【基本編】Django REST framework (DRF)チュートリアル-Slack風サービスを開発する-

前回は以下の記事でDRFの概念について書いてみました。

ということで今回は、実際に開発をしていこうと思います!(読んでいない方は是非読んで、イメージを掴むことをおすすめします。)
大まかな流れは以下になります。長いので別記事にして投稿予定です。
【設計編】開発するものの概要や簡単な設計書の紹介
【基本編】簡単なCRUDのAPIを作ってみる(本記事)
【応用編】ちょっと特殊な部分を説明
【TEST編】DRFのTESTについて
【CI編】Circle ci , Heroku でサーバーにあげてみる
【Front編】vue.jsで実際にAPIのbindingをしてみる

また本記事もかなり長いですが、DRFの基本的な開発だけ知りたいかたはこちらまで飛んでもらっても大丈夫です。

開発環境を整える

今回はDockerを使って開発環境を整えます。(本来であればこの部分の解説だけでひとつの記事になるくらいのボリュームなのですが、それは余裕ができたら書きます。)

DockerをPCにインストールしていないかたはインストールし、docker-compose が使えるようにしてください。mac docker install や windows docker installでぐぐるとわかりやすい記事があると思います。

$ docker-compose --version
docker-compose version: 1.6.2

上記のようになればdockerの準備はOKです。

あとはbaseとなるSource Code をgitからClone しましょう。自分の好きなディレクトリに移動してから以下のコマンドを実施してください。

git clone https://github.com/1ssei/DjangoRESTFramework-docker-heroku-base.git

これで準備はほとんどOKです。

もし、IDEが入っていない人にはVisual Studio Codeをおすすめします。

そしてPython3 をAnacondaか何かでInstallしてflake8を有効にしたりすると綺麗なコードを書きやすくなります。

サーバーを動かしてみる

先ほどCloneしたディレクトリで以下のコマンドを実施します。

docker-compose up

上記のコマンドでDRFに必要な物をinstallし、サーバーを開始してくれます。

web_1  | System check identified no issues (0 silenced).
web_1 | November 19, 2019 - 16:08:07
web_1 | Django version 2.2.4, using settings 'api.settings'
web_1 | Starting development server at http://0.0.0.0:8000/
web_1 | Quit the server with CONTROL-C.

こんな感じのものが表示されたらブラウザでhttp://localhost:8000/にアクセスしてみましょう。以下が表示されたらサーバーの起動ができました。

{"test": "test"}

ちなみに、pipなどで必要な物をインストールする場合にはrequirements.txtに必要な物を追記し、以下のコマンドを実行する必要があります。

docker-compose build
docker-compose up

UserのApplicationを作成する

さて、早速APIを作るぞ!!といきたいところですが、Djangoの注意点というか最初にやっておくべきものとしてUser modelのオーバーライドがあります。Userの登録が必要ないサービスなどでは関係ないのですが、今回はUserの登録が必要なサービスを想定しているので始めにやっておきます。

以下のコマンドでuserというapplicationを作成します。

docker exec -it djangorestframework-docker-heroku-base_web_1 /bin/bash -c "cd api && python manage.py startapp users"

DjangoのApplicationとはhttps://docs.djangoproject.com/ja/2.2/intro/tutorial01/#creating-the-polls-app こちらで細かく説明がありますが、ざっくりいうと似ている処理をまとめたもののイメージです。今回だとSlack風サービスというprojectのなかでusers application, chats applicationを作ります。notificationとかを追加する場合にはnotification applicationを作ったりします。application名は複数形にするのが慣しです。

するとapi/usersというフォルダが作成されます。

そしてusers applicationをprojectに認識させるためにapi/api/settings.pyのINSTALLED_APPSにusersを追記しましょう。

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
.....
    'users']

そしてDjangoのdefaultのuserではなく、今回作った物をuserのmodelとして認識させるために以下もapi/api/settings.pyに追記します。

AUTH_USER_MODEL = 'users.User'

あとは作成したUserのmodelを自分好みにします。今回は以下にしました。

from django.db import models
from django.contrib.auth.models \
    import AbstractBaseUser, PermissionsMixin, UserManager
from django.contrib.auth.validators import UnicodeUsernameValidator
from django.utils import timezone
from django.utils.translation import gettext_lazy as _


class MyValidator(UnicodeUsernameValidator):
    regex = r'^[\w.@+\- ]+$'


class User(AbstractBaseUser, PermissionsMixin):
    username_validator = MyValidator()
    username = models.CharField(
        _('username'),
        max_length=150,
        unique=True,
        help_text=_(
            'Required. 150 characters or @/./+/-/_ only.'),
        validators=[username_validator],
        error_messages={
            'unique': _("A user with that username already exists."),
        },
    )
    email = models.EmailField(_('email address'), blank=True)
    is_staff = models.BooleanField(
        _('staff status'),
        default=False,
        help_text=_(
            'Designates whether the user can log into this admin site.'),
    )
    is_active = models.BooleanField(
        _('active'),
        default=True,
        help_text=_(
            'Designates whether this user should be treated as active. '
            'Unselect this instead of deleting accounts.'
        ),
    )
    date_joined = models.DateTimeField(_('date joined'), default=timezone.now)
    objects = UserManager()
    EMAIL_FIELD = 'email'
    USERNAME_FIELD = 'username'
    REQUIRED_FIELDS = ['email', ]
    # image = models.ImageField(upload_to='user', blank=True, null=True)

    class Meta:
        verbose_name = _('user')
        verbose_name_plural = _('users')

    def clean(self):
        super().clean()
        self.email = self.__class__.objects.normalize_email(self.email)

こちらから必要な部分だけとった形です。https://github.com/django/django/blob/master/django/contrib/auth/models.py

そしてmodelを更新したらそれをDBにも反映させないと意味がないので以下のコマンドを実行します。

docker exec -it djangorestframework-docker-heroku-base_web_1 /bin/bash -c "cd api && python manage.py makemigrations"
 docker exec -it djangorestframework-docker-heroku-base_web_1 /bin/bash -c "cd api && python manage.py migrate"

また、今後APIのtestとかする中で一人はUserがいたほうがいいと思うので作りましょう。以下で管理者を作成することができます。

docker exec -it djangorestframework-docker-heroku-base_web_1 /bin/bash -c "cd api && python manage.py createsuperuser"

username,mail,passwoedを入力し、http://localhost:8000/admin/こちらにアクセスしましょう。usernameとpasswordを求められるので先ほど作成したusername,passwordでloginしましょう。loginに成功したらuserの作成は完了です。

threadのAPIを作成する(メイン)

やっと本題です。ここまでで結構な量になってしまいましたが後少し頑張りましょう。

おさらいですが、APIを作るにはapplicationを作成し、modelを作って、serializerでjsonの形を決めて、viewsで処理を決めてurlsでルーティングする必要がありますのでひとつひとつ書いていきます。

slack風アプリに必要な機能を全て解説するととても長文になってしまうので、ここではthreadのCRUDのAPIの作り方について説明していきます。

applicationを作成する

現時点ではusersのapplicationしか作成していません。しかし、これから必要になる機能はusersとしてまとめるよりはchat周りの機能としてまとめたほうが意味的にはわかりやすいと思います。なのでまずはchat関連の処理をまとめたapplicationを作成します。

usersのapplicationを作成したときのように以下のコマンドを実行します。

docker exec -it djangorestframework-docker-heroku-base_web_1 /bin/bash -c "cd api && python manage.py startapp chats"

そしてchats applicationをprojectに認識させるためにapi/api/settings.pyのINSTALLED_APPSにchatsを追記しましょう。

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
.....
    'users',
    'chats']

models.py

applicationを作成したらmodels.pyから編集していきましょう。こちらはDBのtableと対応する部分ですね。今回は以下のようにしました。

from django.db import models
import users.models


class Thread(models.Model):
    owner = models.ForeignKey(users.models.User, on_delete=models.CASCADE)
    title = models.CharField(max_length=200)
    created_at = models.DateTimeField('作成日時', auto_now_add=True)
    updated_at = models.DateTimeField('更新日時', auto_now=True)
    is_public = models.BooleanField(default=False)

ownerというのはthreadを作った人で、これは外部キーでUser modelを指定しています。titleは文字列、created_at,updated_atは日付,is_publicは真偽値です。
カラムの型についてはdjangoの公式サイトを見るといいと思います。

そしてこれだけではまだダメですね。実際にDBに反映させるために以下のコマンドを実施します。

docker exec -it djangorestframework-docker-heroku-base_web_1 /bin/bash -c "cd api && python manage.py makemigrations" docker exec -it djangorestframework-docker-heroku-base_web_1 /bin/bash -c "cd api && python manage.py migrate"

DBに反映されたかを管理画面で確かめてみましょう。
まずはapi/chats/admin.pyを以下のように編集しましょう。

from django.contrib import admin
from . import models

admin.site.register(models.Thread)

そしてhttp://localhost:8000/admin/こちらにアクセスし、threadが一覧に表示されていればOKです。

serializer.py

続いてはserializer.pyです。models.pyでDBとObjectの連携を完了したので、Objectとjsonの連携をserializerで記述していきます。api/chats/serializer.pyを作成し以下をコピペします。

from rest_framework import serializers
from . import models
import users.serializer as users_serializer


class ThreadSerializer(serializers.ModelSerializer):
    class Meta:
        model = models.Thread
        fields = '__all__'

これが意味するのはとてもシンプルです。
model = models.ThreadでThreadというmodelを対象にしたserializerと定義し、fields = ‘__all__’で全てのfieldを変換の対象にするという意味です。

これだけだと逆にわかりにくいかもしれませんので例を出します。
例えば、この記述だと、threadの中のownerについてはuserのidが返されます。そこでownerについてはusernameを返したい時は以下のようにすることでjsonに渡す時に変換を加えることができたりします。

from rest_framework import serializers
from . import models
import users.serializer as users_serializer


class ThreadSerializer(serializers.ModelSerializer):
    owner = serializers.SerializerMethodField()

    def get_owner(self, instance):
        owner = instance.owner
        return owner.username

    class Meta:
        model = models.Thread
        fields = '__all__'

また、例えばuserのmodelを考えた時に表示させたくないfieldがある場合はfieldを指定することで表示させるものと表示させないものを分けることができます。

DRFではserializerがあることでviews.pyやmodels.pyが大きくなりすぎるのを防いでくれています。serializer.pyについては次回の記事でもう少し詳細に記載します。

views.py

続いてviews.pyです。こちらが実際の処理を記述する部分になりますが、基本的には対象となるmodelを指定し、それをどのserializerで変換するのかを書くだけです。

from rest_framework import viewsets, mixins
from . import models
from . import serializer


class ThreadViewSet(viewsets.ModelViewSet):
    """
    Thread CRUD, only owner can patch and delete thread.
    """

    queryset = models.Thread.objects.all()
    serializer_class = serializer.ThreadSerializer

ThreadViewSetの継承元となるModelViewSetというのは自動的にCRUD 4つのAPIを作ってくれます。具体的には次にブラウザでみてみましょう。

urls.py

views.pyで処理を決めたのであとはルーティングをするだけです。
api/chats/urls.pyに以下を記述します。

from rest_framework import routers
from . import views


router = routers.DefaultRouter()
router.register(r'threads', views.ThreadViewSet, base_name='threads')

これが意味するのはchats applicationの/threads/ にきたら先ほど定義したThreadViewSetの処理を行うという意味です。
あとはproject側にchats applicationのpathを教えてあげるだけです。
api/api/urls.pyに以下を記載します。

from django.conf.urls import url, include
from django.contrib import admin
from django.urls import path
from . import views
from chats.urls import router as chats_router
from rest_framework import routers


api_router = routers.DefaultRouter()
api_router.registry.extend(chats_router.registry)
urlpatterns = [
    url(r'^admin/', admin.site.urls),
    path('', views.TestView, name='test'),
    url(r'^v1/', include(api_router.urls)),
]

これで http://localhost:8000/v1/ にアクセスしてください。
そうすると以下のような画面になると思います。

これはAPIの一覧画面です。なんかっぽくなってきましたね。

ここのUIはSwaggerにしたりすることもできますが、私はDefaultの見た目が割と好きで使ってます。

それでは http://localhost:8000/v1/threads/ を押してみましょう。
ここでPOSTを使えばDataが作成でき、作成したデータの一覧を見ることもできます。データを作ったあとは http://localhost:8000/v1/threads/1/ URLの最後に作成されたidを入れればそこでPATCHを使ってデータの編集ができたりDELETEでデータの削除ができます。
実際に遊んでみてください。

つまりCRUD(Create,Read,Update,Delete)全てのREST APIを作ることができました!!!!!

Permission

これではダメなんです!!!!
注意しないといけないのは権限周りです。railsでも同じなのですがAPIだけであれば本当にすぐに作ることはできるのですが、権限周りは脆弱性になりやすいので注意して作っていきます。
また、今回は記事を読みやすくするためにviews.pyで処理を作ってから権限周りを考えますが、本来であれば、権限周りを含めたTEST CASEを作成してから実装することを強くおすすめします。TDDまでは行かなくても開発する時にはTESTCaseがあったほうがいいです。というかないと何を開発していいのかわかりません。
以下が今回の簡単なTEST CASEです。こちらを一つずつ対応していきます。

CREATE TEST CASE

  • LoginしていないuserはThreadの作成を行うことができない
  • Login user はThreadの作成を行うことができる
  • Request Body のownerは自分のID以外を使うとエラーになる(なりすまし防止)
  • titleが201文字だとエラー

READ TEST CASE

  • retreive (GET)は実装しない
  • publicなthreadはthreadの一覧に表示される
  • privateなthreadはthreadの一覧に表示されない

UPDATE TEST CASE

  • ownerは自分のthreadの編集を行うことができる
  • ownerではない人はthreadの編集を行うことができない

DELETE TEST CASE

  • ownerはThreadの削除を行うことができる
  • ownerではない人はThreadの削除を行うことができない

Permission実装

上記に対応するためにviews.pyを以下のように編集します。

from rest_framework import viewsets, mixins
from . import models
from . import serializer
from django.contrib.auth.mixins import UserPassesTestMixin
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.exceptions import PermissionDenied, MethodNotAllowed


class ThreadPermission(UserPassesTestMixin):
    raise_exception = True

    def test_func(self):
        request = self.request
        userId = request.user.id
        if (self.request.method == 'GET') and ('pk' in self.kwargs):
            raise MethodNotAllowed('Retrieve is not allowed')
        if request.method in permissions.SAFE_METHODS:
            return True
        if not request.user.is_authenticated:
            return False
        if request.method == 'PUT':
            raise MethodNotAllowed('PUT')
        if request.method == 'POST':
            if int(request.POST['owner']) != userId:
                raise PermissionDenied("owner in post data is not yours")
        if (request.method == 'PATCH') or (request.method == 'DELETE'):
            original_project = model.objects.get(pk=self.kwargs['pk'])
            owner = original_project.owner.id
            if owner != userId:
                raise PermissionDenied(
                    "you can not patch or delete data which other people made")
        return True


class ThreadViewSet(ThreadPermission, viewsets.ModelViewSet):
    """
    Thread CRUD, only owner can patch and delete thread.
    """

    def get_queryset(self):
        if self.request.method == 'GET':
            return models.Thread.objects.all().filter(
                is_public=True)
        return models.Thread.objects.all()
    serializer_class = serializer.ThreadSerializer

いきなり長くなりましたが一つ一つ見ていきます。
まずThreadPermissionというclassをUserPassesTestMixinを継承して作成します。
ここでreturn Falseにすると、responseは404になります。
404以外を返したい場合にはrest_framework.exceptions.PermissionDenied などのexceptionをraiseしてあげれば大丈夫です。exceptionについてはこちらの公式サイトで紹介されていますが、個人的に使うのはPermissionDeniedとMethodNotAllowedです。
self.kwargs[‘pk’]はurlの最後のid,pkです。例えばrequest のurlがhttp://localhost:8000/v1/threads/1/  の場合にはself.kwargs[‘pk’]は1になります。
POST dataの中身を確認する必要がある場合にはrequest.POST[‘owner’] このようにすることで取得することができます。
privateなthreadを一覧に表示させないためにはPermissionではなく、queryset側で対応します。今まではqueryset = models.Thread.objects.all() で全てのDataを対象にしていました。しかし、GETの場合にはprivateな物だけを見せる必要があります。
なのでGETのときはis_public=Trueのものにfilterしています。

しかし、permissionが長いですよね。
Model,TableごとにAdminしかさわれないようにするpermissionなどはdjango側で用意しています。
リソース、行ごとのpermissionはdjango-guardian、django-rulesなどで対応することが可能です。ただし個人的には少しわかりにくいです。

ということで自分で関数を作ってみましょう。
どの部分を関数とするか、これは開発するサービスなどによっても異なると思いますが、今回はapi/permission.pyというファイルを作成し、以下の関数を作成してみました。

from rest_framework import permissions
from rest_framework.exceptions import PermissionDenied, MethodNotAllowed


def OwnerPermission(self, model):
    request = self.request
    userId = request.user.id
    if request.method in permissions.SAFE_METHODS:
        return True
    if not request.user.is_authenticated:
        return False
    if request.method == 'PUT':
        raise MethodNotAllowed('PUT')
    if request.method == 'POST':
        if int(request.POST['owner']) != userId:
            raise PermissionDenied("owner in post data is not yours")
    if (request.method == 'PATCH') or (request.method == 'DELETE'):
        original_project = model.objects.get(pk=self.kwargs['pk'])
        owner = original_project.owner.id
        if owner != userId:
            raise PermissionDenied(
                "you can not patch or delete data which other people made")
    return True

これは簡単に説明すると、「login userはdataを作れるけど、それの編集権限と削除権限はそのdataを作った人だけ、GETは誰でもできる」という権限です。
具体的には先ほど作ったTEST CASEの以下に対応します。(対応しない部分は打ち消し線です。またThreadと先ほど記載した部分をDataに変換しています。)

CREATE TEST CASE

  • LoginしていないuserはDataの作成を行うことができない
  • Login user はDataの作成を行うことができる
  • Request Body のownerは自分のID以外を使うとエラーになる(なりすまし防止)
  • titleが201文字だとエラー

READ TEST CASE

  • retreive (GET)は実装しない
  • publicなthreadはthreadの一覧に表示される
  • privateなthreadはthreadの一覧に表示されない

UPDATE TEST CASE

  • ownerは自分のDataの編集を行うことができる
  • ownerではない人はDataの編集を行うことができない

DELETE TEST CASE

  • ownerはDataの削除を行うことができる
  • ownerではない人はDataの削除を行うことができない


これは、今後作成予定のCommentなどでも使えますし、Userが自分のDataを作れるサービスでは使えると思います。(社内PortalのようなtoBから、掲示板、クラウドファンディング、メルカリのようなC2Cも)

上記のFileを作った後のapi/chats/views.pyは以下のようになります。だいぶすっきりします。

from rest_framework import viewsets, mixins
from . import models
from . import serializer
from api import permission
from django.contrib.auth.mixins import UserPassesTestMixin
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.exceptions import PermissionDenied, MethodNotAllowed


class ThreadPermission(UserPassesTestMixin):
    raise_exception = True

    def test_func(self):
        if not permission.OwnerPermission(self, models.Thread):
            return False
        if (self.request.method == 'GET') and ('pk' in self.kwargs):
            raise MethodNotAllowed('Retrieve is not allowed')
        return True


class ThreadViewSet(ThreadPermission, viewsets.ModelViewSet):
    """
    Thread CRUD, only owner can patch and delete thread.
    """

    def get_queryset(self):
        if self.request.method == 'GET':
            return models.Thread.objects.all().filter(
                is_public=True).order_by('title')
        return models.Thread.objects.all()
    serializer_class = serializer.ThreadSerializer

本来であれば、実装する前、もしくは実装後に単体TESTを記述する必要があるのですがTEST周りについてはちょっと長くなるので別の記事にします。

これで基本的なAPIの作り方は終了になります。
あとはfilteringやsort,pagingなどの細かい部分、あとはCRUDの処理をオーバーライドしたい場合などの説明を次の記事で紹介していきます。