DRFでシリアライザのForeignKeyフィールドをPOST時はプライマリーキーを渡し、GET時は展開する

やりたいこと

Django REST framework の ModelSerializer では, ForeignKey のフィールドは, シリアライズ/デシリアライズするときにネストを展開してしまいます.

例をあげますと,

from django.db import models
import uuid


class ForeignModel(models.Model):
    id = models.UUIDField(
        default=uuid.uuid4,
        primary_key=True,
        editable=False
    )
    name = models.CharField(max_length=255, unique=True)


class SampleModel(models.Model):
    id = models.UUIDField(
        default=uuid.uuid4,
        primary_key=True,
        editable=False
    )
    foreign = models.ForeignKey(ForeignModel, on_delete=models.CASCADE)

のようにモデル定義されているとき, GET メソッドのレスポンスは

{
  "id": "xxxxxx",
  "foreign": {
    "id": "yyyyy",
    "name": "myname"
  }
}

このように展開されて, POST メソッドのパラメータは,

{
  "foreign": {
    "name": "myname"
  }
}

の形で指定する必要があります.

GET メソッドに関しては望ましいですが, POST メソッドのパラメータに関しては既存のオブジェクトのプライマリーキーが欲しい場合が多いと思います.

ということで,

  • GET メソッドでは, インスタンスを JSON に展開する
  • POST のパラメータでは, プライマリーキーのみを受け取る

という形でシリアライザを実装するのが主題です.

環境

環境は以下の通りです.

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.6
BuildVersion:   19G2021
$ python -V
Python 3.8.5
$ pip list
Package                   Version
------------------------- ---------
Django                    3.0.5
djangorestframework       3.11.0
drf-yasg                  1.17.1
packaging                 20.4

解決策

シリアライザのフィールドには, write_onlyread_only を指定ができるので, read_only を指定した GET メソッド用のシリアライザフィールドと, write_only を指定した POST メソッドのシリアライザフィールドを定義してあげることで, 期待する動作を実装できます.

GET メソッドを展開して受け取る

GET メソッドの要求は元々満たしていますが, 私は, API ドキュメントの自動生成ツールである drf_yasg を使っていて, 適切なビルドのため MethodSerializer にて実装します.

from rest_framework import serializers
from rest_framework.utils.serializer_helpers import ReturnDict
from drf_yasg.utils import swagger_serializer_method
from typing import Dict, Any

from .models import ForeignModel, SampleModel


class ForeignSerializer(serializers.ModelSerializer):
    class Meta:
        model = ForeignModel
        fields = ('pk', 'name')


class SampleSerializer(serializers.ModelSerializer):
    foreign = serializers.SerializerMethodField(read_only=True)

    class Meta:
        model = SampleModel
        fields = ('pk', 'foreign',)

    @swagger_serializer_method(serializer_or_field=ForeignSerializer)
    def get_foreign(self, instance: SampleModel) -> ReturnDict:
        return ForeignSerializer(instance.foreign).data

drf_yasg を使っていないなら Meta.fields に追加して, read_only を指定してあげるだけで大丈夫なはずです.

POST パラメータには, プライマリーキーを渡す

POST メソッド用のフィールドには PrimaryKeyRelatedField を使います.

write_only にしつつ, シリアライズメソッドを上書きすることで対応します.

from rest_framework import serializers
from rest_framework.utils.serializer_helpers import ReturnDict
from drf_yasg.utils import swagger_serializer_method
from typing import Dict, Any

from .models import ForeignModel, SampleModel


class ForeignSerializer(serializers.ModelSerializer):
    class Meta:
        model = ForeignModel
        fields = ('pk', 'name')


class SampleSerializer(serializers.ModelSerializer):
    foreign = serializers.SerializerMethodField(read_only=True)
    foreign_pk = serializers.PrimaryKeyRelatedField(
        queryset=ForeignModel.objects.all(),
        write_only=True
    )

    class Meta:
        model = SampleModel
        fields = ('pk', 'foreign', 'foreign_pk')

    @swagger_serializer_method(serializer_or_field=ForeignSerializer)
    def get_foreign(self, instance: SampleModel) -> ReturnDict:
        return ForeignSerializer(instance.foreign).data

    def create(self, validated_data: Dict[str, Any]) -> SampleModel:
        foreign_pk = validated_data.get('foreign_pk', None)

        if foreign_pk is not None:
            validated_data['foreign'] = foreign_pk
            del validated_data['foreign_pk']

        return super().create(validated_data)

これで,

  • GET メソッド => foreign にインスタンス情報が展開される
  • POST メソッド => foreign_pk にプライマリーキーを渡す

という形になりました.

実際に API を叩いてみる

一応 API を叩いてみます. クライアントは, HTTPie を使っています.

$ http http://localhost:8080/samples/
HTTP/1.1 200 OK
Allow: GET, POST, HEAD, OPTIONS
Content-Length: 249
Content-Type: application/json
Date: Thu, 01 Oct 2020 21:37:41 GMT
Server: WSGIServer/0.2 CPython/3.8.5
Vary: Accept, Cookie
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

[
    {
        "foreign": {
            "name": "myname",
            "pk": "2880c984-c284-4229-a1ca-c159e418511c"
        },
        "pk": "7d97ed86-55b5-4b5a-8e9e-bc26f2badc34"
    }
]

$ http POST http://localhost:8080/samples/ foreign_pk=2880c984-c284-4229-a1ca-c159e418511c
HTTP/1.1 201 Created
Allow: GET, POST, HEAD, OPTIONS
Content-Length: 123
Content-Type: application/json
Date: Thu, 01 Oct 2020 21:40:07 GMT
Server: WSGIServer/0.2 CPython/3.8.5
Vary: Accept, Cookie
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

{
    "foreign": {
        "name": "myname",
        "pk": "2880c984-c284-4229-a1ca-c159e418511c"
    },
    "pk": "5889a2f0-0195-412b-b1f0-a4a8bd9efdb9"
}

見ての通り, 期待通りの動作をしてくれているようです.