Django REST frameworkのdrf-nested-routersで階層構造のURLを実装する


たとえば特定のカテゴリに所属するアイテムの一覧を取得する場合に/categories/<category>/items/みたいな感じのURLにしたくてdrf-nested-routers使ってみた。

drf-nested-routersで実装してみる

READMEを見ながら下記のURLのように特定のCategoryの下にItemがくるようにネストしたURLを実装する。

/categories
/categories/{pk}
/categories/{category_pk}/items
/categories/{category_pk}/items/{pk}

models.py

まずはカテゴリとアイテムのモデルを実装。

class Category(models.Model):
    name = models.CharField(max_length=30)
    slug = models.SlugField(unique=True)


class Item(models.Model):
    name = models.CharField(max_length=100)
    category = models.ForeignKey(Category)
    display_order = models.IntegerField(default=0, help_text='表示順')

serializers.py

カテゴリとアイテムのserializersも実装する。

class CategorySerializer(serializers.ModelSerializer):
    class Meta:
        model = Category
        fields = (
            'pk',
            'name',
            'slug',
        )


class ItemSerializer(serializers.ModelSerializer):
    class Meta:
        model = Item
        fields = (
            'pk',
            'name',
            'display_order',
            'category',
        )

views.py

後述するがurlの生成はrouterを使うのだけれどもpkcategory_pkが数字になるように指定ができない。
なのでうっかり/category/hoge/itemsとか/category/1/items/hogeみたいに入力されるとValueErrorが発生してInternalServerErrorになってしまうということに気をつけなければいけない。

ValueErrorを回避するためにretrieve()の方はdjango.shortcuts.get_object_or_404じゃなくてrest_framework.generics.get_object_or_404を使用する。
ただ、list()のほうが残念ながらrest_framework.generics.get_list_or_404は存在しないのでrest_framework.generics.get_object_or_404に倣ってTypeErrorとValuErrorが発生した場合はHttp404をraiseする。

from rest_framework.generics import get_object_or_404

class CategoryViewSet(viewsets.ModelViewSet):
    queryset = Category.objects.all()
    serializer_class = CategorySerializer


class ItemViewSet(viewsets.ReadOnlyModelViewSet):
    serializer_class = ItemSerializer
    queryset = Item.objects.all()

    def retrieve(self, request, pk=None, category_pk=None):
        item = get_object_or_404(self.queryset, pk=pk, category__pk=category_pk)
        serializer = self.get_serializer(item)
        return Response(serializer.data)

    def list(self, request, category_pk=None):
        try:
            items = get_list_or_404(self.queryset, category__pk=category_pk)
        except (TypeError, ValueError):
            raise Http404
        else:
            serializer = self.get_serializer(items, many=True)
            return Response(serializer.data)

urls.py

NestedSimpleRouterを使ってルーティングする。

from rest_framework_nested import routers

router = routers.SimpleRouter(trailing_slash=False)
router.register(r'categories', CategoryViewSet)

categories_router = routers.NestedSimpleRouter(
    router, r'categories', lookup='category', trailing_slash=False)
categories_router.register(r'items', ItemViewSet)

urlpatterns = [
    url(r'^', include(router.urls)),
    url(r'^', include(categories_router.urls)),
]

上記の実装で登録されるURLは下記の通り。

categories$ [name='category-list']
categories/(?P<pk>[^/.]+)$ [name='category-detail']
categories/(?P<categoary_pk>[^/.]+)/items$ [name='item-list']
categories/(?P<categoary_pk>[^/.]+)/items/(?P<pk>[^/.]+)$ [name='item-detail']

更新系のメソッドの実装

READMEを見た感じだと子供側のViewSetにlist()retrieve()以外の実装のサンプルがなかったので試してみた。

create

気を付けなくちゃいけないのは2点。
1点目はclientから受け取ったリクエストのデータに'category'が存在しても無視してcategory_pkを使用すること(もしくはエラーで弾くこと)。
2点目はcategoryの存在チェックも忘れずにすること。
それらに気をつけながら実装するとこんな感じ。

    def create(self, request, category_pk=None):
        category = get_object_or_404(Category.objects, pk=category_pk)
        request.data['category'] = category.pk
        return super(ItemViewSet, self).create(request)

update

基本的に気をつけることはcreateと同じ。
ただ、clientから受け取ったデータにcategoryの指定があった場合の処理は悩ましいところではあるけどvalidationで弾いてしまう。

views.py:

    def update(self, request, category_pk=None, *args, **kwargs):
        category = get_object_or_404(Category.objects, pk=category_pk)
        request.data['category'] = category.pk
        return super(ItemViewSet, self).update(request, *args, **kwargs)

serializers.py:

class ItemSerializer(serializers.ModelSerializer):
    class Meta:
        model = Item
        fields = (
            'pk',
            'name',
            'display_order',
            'category',
        )

    def validate_category(self, category):
        if self.instance and self.instance.category_id != category.id:
            raise serializers.ValidationError("can not update to category.")

        return category

categoryを変更できるようにしても良いと思うのだけれど、その場合のステータスコードはstatus.HTTP_204_NO_CONTENTあたりが適切なのかなぁ。
PUTしたときのURLにはリソースが存在しなくなるわけだし。

destroy

destroyも基本的に気をつけることはcreateと同じ。
ただ、categoryを取得する必要はないのでget_object_or_404でitemを取得するように実装する。

    def destroy(self, request, pk=None, category_pk=None):
        item = get_object_or_404(self.queryset, pk=pk, category__pk=category_pk)
        self.perform_destroy(item)
        return Response(status=status.HTTP_204_NO_CONTENT)

子をソートする

/categories/{category_pk}/itemsにアクセスした結果をソートするのは簡単。
ViewSetのquery_setを修正するだけ。

class ItemViewSet(viewsets.ModelViewSet):
    serializer_class = ItemSerializer
    queryset = Item.objects.all().order_by('display_order')

ただ上記の方法では/categories/{category_pk}の結果にitemsを入れて、そいつをソートすることはできない。
この場合はModelのorderingで指定するしか方法がなさそう。

serializers.py:

class CategorySerializer(serializers.ModelSerializer):
    class Meta:
        model = Category
        fields = (
            'pk',
            'name',
            'slug',
            'item_set'
        )

    item_set = ItemSerializer(many=True, read_only=True)

models.py:

class Item(models.Model):
    class Meta(object):
        ordering = ('display_order', 'pk')

    name = models.CharField(max_length=100)
    category = models.ForeignKey(Category)
    display_order = models.IntegerField(default=0, help_text='表示順')

URLにslugを使う

categories/1/itemsではなくてcategories/some-slug/itemsってしたい場合はViewSetのlookup_fieldを指定するだけ。

class CategoryViewSet(viewsets.ModelViewSet):
    queryset = Category.objects.all()
    serializer_class = CategorySerializer
    lookup_field = 'slug'

この場合に登録されるURLは下記の通り。

categories$ [name='category-list']
categories/(?P<slug>[^/.]+)$ [name='category-detail']
categories/(?P<category_slug>[^/.]+)/items$ [name='item-list']
categories/(?P<category_slug>[^/.]+)/items/(?P<pk>[^/.]+)$ [name='item-detail']

ItemViewSetに渡される引数がcategory_slugになるので各種メソッドの引数はそれに合わせる必要がある。

class ItemViewSet(viewsets.ModelViewSet):
    serializer_class = ItemSerializer
    queryset = Item.objects.all().order_by('display_order')

    def retrieve(self, request, pk=None, category_slug=None):
        item = get_object_or_404(self.queryset, pk=pk, category__slug=category_slug)
        serializer = self.get_serializer(item)
        return Response(serializer.data)

list_ruote

list_routeも普通に使える。
例えばitemを指定の順番に並び替えたい場合にcategories/1/items/sortでdisplay_orderを更新するメソッドを実装したりする。

class ItemViewSet(viewsets.ModelViewSet):
    (...省略)

    @list_route(methods=['patch'])
    @transaction.atomic
    def sort(self, request, category_pk=None):
        items = self.queryset.filter(category__pk=category_pk)

        for i, pk in enumerate(request.data.get('item_pks', [])):
            items.filter(pk=pk).update(display_order=i + 1)

        return Response()

ソースコード

気が向いたらどっかにサンプルコードを上げる。

各種バージョン

Python==3.6
Django==1.10.6
djangorestframework==3.6.2
drf-nested-routers==0.90.0

参考サイト