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

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

# drf-nested-routersで実装してみる
[README](https://github.com/alanjds/drf-nested-routers)を見ながら下記の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を使うのだけれども`pk`や`category_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](https://github.com/alanjds/drf-nested-routers)を見た感じだと子供側の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

# 参考サイト
https://github.com/alanjds/drf-nested-routers