🌭

DRFを使ったときに親フィールドの値を参照して子のバリデーションを変更する方法

2021/10/01に公開

概要

Django REST frameworkのシリアライザのフィールドで他のシリアライザを利用している場合に、親シリアライザの値によって子のシリアライザの必須条件を決定する方法についての内容です。

実装のイメージとしては以下のような感じです。

class 子シリアライザ(serializers.Serializer):
    条件が変わる = serializers.IntegerField


class 親シリアライザ(serializers.Serializer):
    子の判定用 = serializers.ChoiceField
    子 = 子シリアライザ()

検証環境について

  • python 3.9.4
  • Django 3.2.5
  • djangorestframework 3.12.4

前提

ニュースを data としてシリアライザに渡した際に、天気に関するニュースであれば注目するかどうかは任意で設定可能です。

しかし、映画に関するニュースであれば注目するかどうかは必須とします。

data の構造は以下を想定します。

field type required note
type string true
article article object true
article.title string true
article.attention_degree integer false or true typeの値が"weather"の場合は任意で指定可能。"movie"の場合は必須で指定すること。

サンプルとしては以下のような json となります。

天気のニュースの場合

{
    "type": "weather",
    "article": {
        "title": "タイトル"
    }
}

映画のニュースの場合

{
    "type": "movie",
    "article": {
        "title": "タイトル",
        "attention_degree": 1
    }
}

以上の想定のもと、Django REST frameworkを利用した実装を考えます。

結論

実装パターン 1~4 まで考えた結果、実装パターン4が最善策であると思いました。

実装パターン1は前提を満たしているものの、実装が煩雑になります。

実装パターン2と3は要件を満たしません。

実装パターン4は前提を満たしつつ、煩雑な実装もありません。拡張性やメンテナンス性を考慮した実装になっており、変更に強い実装です。

実装パターン1

親のシリアライザとして、NewsSerializerを作成し、子のシリアライザとしてArticleSerializerを作成します。

実装パターン1では NewsSerializer 内に validate 関数を用意して article.attentionの値の有無を検証しています。

from unittest import TestCase

from rest_framework import serializers


class ArticleSerializer(serializers.Serializer):
    title = serializers.CharField(max_length=120)
    attention_degree = serializers.IntegerField(required=False)


class NewsSerializer(serializers.Serializer):
    type = serializers.ChoiceField(choices=(
        ('weather', '天気'),
        ('movie', '映画')
    ))
    article = ArticleSerializer()

    def validate(self, attrs):
        if attrs.get('type') == 'movie':
            attention_degree = attrs.get('article').get('attention_degree')
            if attention_degree is None:
                raise serializers.ValidationError('attention_degree is required')
        return attrs

この実装の利点は「子のシリアライザを再利用できる」点にあります。

子のシリアライザは親によって動きを変えないため、子を利用するシリアライザによって自由に動きを変えることができます。

デメリットとしては、再利用性が高いため結合度が高くなってしまう可能性があります。
「外でも使える自由なシリアライザだから、使っても良いよね」という考えの元に、あらゆる箇所で利用される可能性があります。

実装パターン2

親のシリアライザとして、NewsSerializerを作成し、子のシリアライザとしてArticleSerializerを作成します。

このパターンでは ArticleSerializer 内に validate_attention 関数を用意し、そこで NewsSerializer の type を取得することでバリデーションを行います。

NewsSerializer から値を取得するために NewsSerializer.__init__ 内で context を更新しています。

from unittest import TestCase

from rest_framework import serializers


class ArticleSerializer(serializers.Serializer):
    title = serializers.CharField(max_length=120)
    # typeが天気の場合は任意, 映画の場合は必須
    attention_degree = serializers.IntegerField(required=False)

    def validate_attention_degree(self, validated_data):
        print('call validate_attention_degree')
        if self.context.get('news_type') == 'movie':
            if validated_data is None:
                raise serializers.ValidationError('attention_degree is required')
        return validated_data


class NewsSerializer(serializers.Serializer):
    type = serializers.ChoiceField(choices=(
        ('weather', '天気'),
        ('movie', '映画')
    ))
    article = ArticleSerializer()

    def __init__(self, instance=None, data=None, **kwargs):
        super().__init__(instance, data, **kwargs)
        self.context.update({
            'news_type': data.get('type')
        })

この実装のメリットは NewsSerializer 内に validate 関数が不要になることで条件分岐による実装ミスを減らすことができる点です。

ただし、子を再利用する際には必ず親シリアライザで self.context.update()の実行が必要なことです。

使用するシリアライザで context に何が必要なのか把握せずに使用した場合に正常に動きません。

また、attention_degree = serializers.IntegerField(required=False)となっているため、以下のコードのようにarticle.attention_degreeが指定されなかった場合はvalidate_attention_degree関数が実行されない点も注意が必要です。

s = NewsSerializer(data={
    'type': 'movie',
    'article': {
        'title': 'タイトル',
        # 'attention_degree': True
    }
})
assert not s.is_valid()

このパターンを利用する場合は、ArticleSerializerの再利用は考えずにNewsSerializerのみで利用するようにしたほうが安全です。

実装パターン3

ArticleSerializer.__init__内で context に入った値を基に required を切り替えています。

ただし、これは上手く動きません。

ArticleSerializer.__init__が実行された段階で context は{}となっているからです。

class ArticleSerializer(serializers.Serializer):
    title = serializers.CharField(max_length=120)
    # typeが天気の場合は任意, 映画の場合は必須
    attention_degree = serializers.IntegerField(required=False)

    def __init__(self, instance=None, data=None, **kwargs):
        super().__init__(instance, data, **kwargs)
        if self.context.get('news_type') == 'movie':
            self.attention_degree.required = True


class NewsSerializer(serializers.Serializer):
    type = serializers.ChoiceField(choices=(
        ('weather', '天気'),
        ('movie', '映画')
    ))
    article = ArticleSerializer()

    def __init__(self, instance=None, data=None, **kwargs):
        super().__init__(instance, data, **kwargs)
        self.context.update({
            'news_type': data.get('type')
        })

また、以下のように NewsSerializer インスタンス生成時に context を渡したとしても上手く動きません。

data = {
    'type': 'movie',
    'article': {
        'title': 'タイトル',
        # 'attention_degree': True
    }
}
s = NewsSerializer(data=data, context={
    'news_type': data.get('type')
})

ArticleSerializer まで context が伝わらないため、{}となります。

実行順序が ArticleSerializer の初期化が行われた後に NewsSerializerの初期化が行われるからです。

そのため NewsSerializer.__init__ の処理を以下のように書き換えても同様の結果になります。

class NewsSerializer(serializers.Serializer):
    type = serializers.ChoiceField(choices=(
        ('weather', '天気'),
        ('movie', '映画')
    ))
    article = ArticleSerializer()

    def __init__(self, instance=None, data=None, **kwargs):
        super(NewsSerializer, self).__init__(instance, data, **kwargs)
        self.fields.get('article').context.update(**self.context)

実装パターン4

自分が思いついた中でこれが最善策です。

ArticleSerializer 作成し、これを継承したクラスとして WeatherArticleSerializer と MovieArticleSerializer を用意します。

同様に NewsSerializer も天気ごとにシリアライザを作成します。

from unittest import TestCase

from rest_framework import serializers


class ArticleSerializer(serializers.Serializer):
    title = serializers.CharField(max_length=120)
    attention_degree: serializers.IntegerField


class WeatherArticleSerializer(ArticleSerializer):
    attention_degree = serializers.IntegerField(required=False)


class MovieArticleSerializer(ArticleSerializer):
    attention_degree = serializers.IntegerField(required=True)


class NewsSerializer(serializers.Serializer):
    type = serializers.ChoiceField(choices=(
        ('weather', '天気'),
        ('movie', '映画')
    ))
    article: ArticleSerializer


class WeatherNewsSerializer(NewsSerializer):
    article = WeatherArticleSerializer()


class MovieNewsSerializer(NewsSerializer):
    article = MovieArticleSerializer()

この実装のメリットは validate 関数が必要ない事や、実装パターン2のときの問題点の注意点がないことです。

また、ニュースのタイプによってシリアライザを用意しているため、それぞれで別の要件が追加された場合にも対応しやすいです。

シリアライザの選択については rest_framework の GenericAPIView.get_serializer_class を利用することで使用するシリアライザを決めることができます。

Discussion