Django REST Frameworkの動作確認 ViewSetを定義する&モデルにUnique制約を追加する

https://programming-gogogogo.hatenablog.com/entry/2023/03/21/063245

上記の記事の続きです。

前回の記事でエンドポイントURLとviewの関数を紐づけることはできたので、今回はViewsetsを定義してエンドポイントと紐づけてみたいと思います。

公式ドキュメントはこのあたりに記載がありました。 https://www.django-rest-framework.org/api-guide/viewsets/

https://www.django-rest-framework.org/tutorial/6-viewsets-and-routers/

REST framework includes an abstraction for dealing with ViewSets, that allows the developer to concentrate on modeling the state and interactions of the API, and leave the URL construction to be handled automatically, based on common conventions.

ViewSetsを規約に基づいてうまく活用することで効率的にAPIが構築できそうです。

ViewSetsを定義するファイルを作成してみます。

touch drfproject/blog/viewsets.py

blog/viewsets.pyに以下のようにUserViewSetを定義しました。

from rest_framework import viewsets
from .models import User
from .serializers import UserSerializer


class UserViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer

viewsets.ModelViewSetを継承しているので、上記だけでCRUD処理が実装されていると思われます。

次にルーティングを定義します。

drfproject/drfproject/urls.pyを以下のように編集しました。

from django.contrib import admin
from django.urls import path, include


urlpatterns = [
    path("admin/", admin.site.urls),
    path('blog/', include('blog.urls'))
]

これでlocalhost:8000/blog/以降のURLをdrfproject/blog/urls.pyに定義すれば良いはず。

drfproject/blog/urls.pyを以下のように編集しました。

from django.urls import path
from blog import views

urlpatterns = [
    path('users_list', views.users_list)
]

views.users_listは以下のように関数型のビューになっています。

from .serializers import UserSerializer
from .models import User
from django.http import JsonResponse


def users_list(request):
    users = User.objects.all()
    serializer = UserSerializer(users, many=True)

    return JsonResponse(serializer.data, safe=False)

ひとまずこのルーティングが動作することを確認します。

python manage.py runserver
curl http://localhost:8000/blog/users_list
[{"id": 1, "name": "testuser", "age": 10}]

ここまでのルーティングは正常に動作していることを確認しました。

次にUserViewSetとエンドポイントを紐づけるルーティングを定義してみます。 エラーになりそうですが、以下のようにUserViewSet.as_view()でpathの第二引数に指定してみました。

from django.urls import path
from blog import views
from .viewsets import UserViewSet


urlpatterns = [
    path('users_list', views.users_list),
    path('user_viewset', UserViewSet.as_view())
]

すると以下のエラーになりました。

raise TypeError("The `actions` argument must be provided when "
TypeError: The `actions` argument must be provided when calling `.as_view()` on a ViewSet. For example `.as_view({'get': 'list'})`

DRFソースコード読んでみると該当のエラーは以下だと思われます。 https://github.com/encode/django-rest-framework/blob/master/rest_framework/viewsets.py#L84

試しにas_viewに引数を追加すると一応サーバーは起動しました。

from django.urls import path
from blog import views
from .viewsets import UserViewSet


urlpatterns = [
    path('users_list', views.users_list),
    path('user_viewset', UserViewSet.as_view({
        'get': 'list'
    }))
]

リクエストも正常に返ってきました。

curl http://localhost:8000/blog/user_viewset
[{"id":1,"name":"testuser","age":10}

しかし、これだとGETのリクエストしか受け付けなさそうな気がするのでPOSTのリクエストも投げてみたいです。

script/post.shを作成してcurlコマンドを実行するスクリプトを書きました。

mkdir script
touch script/post.sh

script/post.sh

curl -X POST -H "Content-Type: application/json" -d '{"name":"testuser2", "age":"100"}' http://localhost:8000/blog/user_viewset

以下のコマンドでpost.shを実行するとPOSTメソッドは許可されていないエラーが発生しました。やっぱりそうですよねという感じです。

sh script/post.sh
{"detail":"Method \"POST\" not allowed."}

CRUD処理ができるViewsetのルーティング方法を調査するにあたって、DRFソースコードのコメントに良さそうな記載がありました。

https://github.com/encode/django-rest-framework/blob/master/rest_framework/viewsets.py#L11

Typically, rather than instantiate views from viewsets directly, you'll register the viewset with a router and let the URL conf be determined automatically. router = DefaultRouter() router.register(r'users', UserViewSet, 'user') urlpatterns = router.urls

viewsetsからviewをインスタンス化するよりも、routerにviewsetを登録しURL confに自動で決定させた方が良いとのこと。

また、公式ドキュメントでは以下のページにも説明がありました。 https://www.django-rest-framework.org/tutorial/6-viewsets-and-routers/#using-routers

Because we're using ViewSet classes rather than View classes, we actually don't need to design the URL conf ourselves. The conventions for wiring up resources into views and urls can be handled automatically, using a Router class. All we need to do is register the appropriate view sets with a router, and let it do the rest.

ViewよりもViewSetの方を使うため、自身でURLを設定する必要はない。Routerクラスを使うことでviewとURLを自動で繋げることができるので、routerにviewsetを登録さえすれば良いっぽいです。

書かれているコードをそのまま真似してdrfproject/blog/urls.pyを修正しました。

from django.urls import path, include
from blog import views
from .viewsets import UserViewSet
from rest_framework.routers import DefaultRouter


router = DefaultRouter()
router.register(r'users_list', views.users_list, basename='users_list')
router.register(r'user_viewset', UserViewSet, basename='user_viewset')

urlpatterns = [
    path('', include(router.urls)),
]

すると以下のエラーが出ました。

AttributeError: 'function' object has no attribute 'get_extra_actions'

そういえばusers_listはViewsetsではなく関数だったので、特定のURLを明示的に指定する必要がありそうです。

drfproject/blog/urls.pyを以下のように修正すると、サーバーが起動しました。

from django.urls import path, include
from blog import views
from .viewsets import UserViewSet
from rest_framework.routers import DefaultRouter


router = DefaultRouter()
router.register(r'user_viewset', UserViewSet, basename='user_viewset')

urlpatterns = [
    path('', include(router.urls)),
    path('users_list', views.users_list)
]

再度curlコマンドを実行してみます。

sh script/post.sh

すると以下のエラーになりました。

RuntimeError: You called this URL via POST, but the URL doesn't end in a slash and you have APPEND_SLASH set. Django can't redirect to the slash URL while maintaining POST data. Change your form to point to localhost:8000/blog/user_viewset/ (note the trailing slash), or set APPEND_SLASH=False in your Django settings.

リクエストのURLの末尾のスラッシュがなかったためエラーになったようです。 script/post.shを修正しました。

curl -X POST -H "Content-Type: application/json" -d '{"name":"testuser2", "age":"100"}' http://localhost:8000/blog/user_viewset/

再度sh post.shを実行すると成功しました。ちなみに何回か同じコマンド実行したのでidは4になっています。

sh script/post.sh
{"id":4,"name":"testuser2","age":100}

再度一覧取得してDBのデータを確認してみます。 これもスクリプト書いておきます。

touch script/list.sh

script/list.sh

curl -H "Content-Type: application/json" http://localhost:8000/blog/user_viewset/

一覧取得してみると、同じPOSTリクエストを続けて投げたので、同じ名前のユーザーが作成されてしまっています。

sh script/list.sh
[{"id":1,"name":"testuser","age":10},{"id":2,"name":"testuser2","age":100},{"id":3,"name":"testuser2","age":100},{"id":4,"name":"testuser2","age":100}]

確かモデルの定義でフィールドをuniqueにできたはずなので試してみます。

drfproject/blog/models.py

from django.db import models


class User(models.Model):
    name = models.CharField(max_length=100, unique=True) # uniqueを追加
    age = models.IntegerField()

おそらくmigrationしないと変更が適用されないはずなので以下を実行します。

python manage.py makemigrations
Migrations for 'blog':
  blog/migrations/0002_alter_user_name.py
    - Alter field name on user
python manage.py migrate

すると以下のエラーになりました。

django.db.utils.IntegrityError: UNIQUE constraint failed: new__blog_user.name

おそらくユニーク制約があるカラムですでに重複が発生していることが問題な気がするので、データを削除してみます。

どうせなので削除用のスクリプトも書いておきます。

touch script/delete.sh

script/delete.shは実行時にターミナルで引数を指定できるように$1で書いてあります。ここがUserモデルのIDになります。

curl -X DELETE -H "Content-Type: application/json" http://localhost:8000/blog/user_viewset/$1/

以下を実行してuser_nameが重複しているデータが消えたことを確認しました。

sh script/delete.sh 2
sh script/delete.sh 3
sh script/delete.sh 4
sh script/list.sh
[{"id":1,"name":"testuser","age":10}]

再度migrateし、成功することを確認しました。

python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, blog, contenttypes, sessions
Running migrations:
  Applying blog.0002_alter_user_name... OK

再度同じPOSTリクエストを送ると一度目は成功し、二度目に失敗したのでnameが重複するuserは登録できなくなっていることが確認できました。

sh script/post.sh
{"id":2,"name":"testuser2","age":100}
sh script/post.sh
{"name":["user with this name already exists."]}