Python
Django
OAuth
まとめ
django-rest-framework
0

Django REST Framework で API サーバーを実装して得た知見まとめ(OAuthもあるよ)

本記事の流れ

  • はじめに
  • 読者の想定
  • この記事に書いてあること
  • Codespot とは
  • Codespot 全体のアーキテクチャ
  • Django を使う理由
  • Django REST framework ( DRF ) も使う理由
  • 具体的な Django + DRF の魅力
  • DRF のシリアライザに一工夫したこと
  • DRF のシリアライザでデータを加工して保存や更新をする時の設計
  • DRF のパーミッションにクセがある
  • me API のような REST でないものでも DRF に乗っかりたい時
  • モノリシックでない Web サービスでの OAuth と認証
  • django-allauth のクセ
    • Twitter OAuth でメールアドレスの取得
    • 元々いたページにリダイレクト
  • あとがき

はじめに

こんにちは株式会社ピケのサーバサイド & インフラを主に担当している古内です。
私は主に Python を使って開発することがほとんどです。特に Django をよく使います。なのでそのあたりの知見等を共有できれば幸いです。
以下は今まで作ってきたもの

  • Frasco - エンジニア向け翻訳メディア
  • Matey - ルームシェアマッチングサービス ( 現在はサービス終了 )
  • Dish - ランチをサクッと決めるアプリ
  • Codespot - 技術記事販売プラットフォーム ( 本記事の題材 )

以下、私の SNS です。

読者の想定

  • Django や Rails、Laravel などのフレームワークを用いて開発した経験がある
  • Django は使ったことあるけど DRF どうなの?

以上の様な方を想定にしています。

この記事に書いてあること

  • Django
    • ORM と 管理画面が便利
  • Django REST framework ( DRF )
    • DRF の恩恵
    • シリアライザに一工夫したこと
    • パーミッションにクセがある
    • me API のような REST でないものでも DRF に乗っかりたい時
  • モノリシックでない Web サービスでの OAuth

Codespot とは

まず、Codespot とは技術記事を有料で販売できるプラットフォームです。
自身の知見を記事としてアウトプットしたいが内容をしっかりとしたものにするとどうしても時間と労力がかかります。
例えば、画像を作ったり、記事用のコードを書いたり、読み手のレベルをなるべく下げるために基礎部分から記事を書いたり…。
とても大変だと思いますし、モチベーションもだんだん下がってしまうと思います。
そこで、記事を有料にすることで書き手のモチベーションを維持し、良質な記事を執筆できるようになります。
これからは価値のある記事の書き手がちゃんと評価され、報酬があれば良いなと思います。
また、書き手だけのメリットだけでなく読み手も良質な記事が読める機会が増えると思います。
エンジニアにハッピーなエコシステムを目標に Codespot の開発 & 運営をがんばります!

Codespot 全体のアーキテクチャ

Codespot.png

Codespot では Django で API 兼管理画面 サーバ、Nuxt でフロントを実装しています。
今回の記事では主に Django ( DRF ) と OAuth 部分について書きます。
そのため、GCP についてや Nuxt については触れられていません。

Django を使う理由

まずは Django についてです。
弊社で Django を使う理由は ORM と管理画面がとても便利で、開発工数を減らすことができる ことです。
なぜ開発工数が減るかというと Django では 「モデルを作る ≒ 管理画面を作る」 で、ほぼ自動的に管理画面を作ることができます。( admin.py を少しだけ書くだけ )
これはスタートアップには本当に嬉しい機能です。スタートアップの初期段階では 「仮説検証 > コードの品質」 のためできるだけコードを書かずに必要最低限でプロダクトを高速に出せるようにしています。

Django REST framework ( DRF ) も使う理由

弊社では Django を使うときは DRF も同時に使っています。
DRF を使うと開発が更に高速化できるためです。
主に恩恵を受けているのが ViewSets で、中でも ModelViewSet が特に便利です。ModelViewSet を使うと 「モデルを作る ≒ CRUD API を作る」 になります。( Serializer を少し書くだけ )

また、djangorestframework-camel-case を使うと更に便利です。キャメルケース ( フロント側 ) とスネークケース ( サーバ側 ) の変換を自動的にやってくれるようにできます。

具体的な Django + DRF の魅力

「DRF 使ったことないよ」って方に向けてこの章を書いています。
使ったことがある方は飛ばしちゃってください。

ここまで「モデルを作る ≒ 管理画面を作る」、「モデルを作る ≒ CRUD API を作る」と書いてきましたが Django + DRF の構成だと モデルを作るだけ でそこそこ動く状態にできるということがわかります。

それでは簡単に作ってみます。
例題は簡単な Article アプリケーションです。
( localhost:8000 で Django を動かしています。)

articles/models.py

from django.db import models


class Article(models.Model):
    """
    記事モデル
    """

    title = models.CharField(verbose_name='タイトル', max_length=255, null=True)
    body = models.TextField(verbose_name='本文', null=True, blank=True)

    def __str__(self):
        return self.title

articles/admin.py

from django.contrib import admin
from articles.models import Article


@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    """
    記事管理画面
    """
    pass

これだけでとりあえず記事の管理画面とモデルが作成できました。

管理画面トップ ( ここにテーブル一覧が出てきます )

管理画面.png

管理画面詳細 ( この画面でデータの作成や編集等行えます )

管理画面詳細.png

続いて、DRF で CRUD を実装します。

articles/views.py

from rest_framework import viewsets
from articles.models import Article
from articles.serializers import ArticleSerialize


class ArticleViewSets(viewsets.ModelViewSet):
    """
    記事 Viewset
    """

    queryset = Article.objects.all()
    serializer_class = ArticleSerialize

articles/serializers.py

from rest_framework import serializers
from articles.models import Article


class ArticleSerialize(serializers.ModelSerializer):
    """
    記事シリアライザ
    """
    class Meta:
        model = Article
        fields = '__all__'

urls.py

from django.contrib import admin
from django.urls import path, include
from rest_framework import routers
from articles.views import ArticleViewSets


router = routers.DefaultRouter(trailing_slash=False)
router.register(r'articles', ArticleViewSets)

urlpatterns = [
    path('api/', include(router.urls)),
    path('admin/', admin.site.urls),
]

settings.py に追記

REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.AllowAny',
    ],
    'DEFAULT_RENDERER_CLASSES': [
        'djangorestframework_camel_case.render.CamelCaseJSONRenderer',
    ],
    'DEFAULT_PARSER_CLASSES': (
        'djangorestframework_camel_case.parser.CamelCaseJSONParser',
    ),
    'TEST_REQUEST_DEFAULT_FORMAT': 'json',
}

これで記事の CRUD API 実装できました。

それでは試してみましょう。

https://localhost:8000/api

apiトップ.png

DRF は開発時 ( DEBUG = True ) のときにこの画像のようにデバッグができます。

この画像では API のエントリーポイントを表示してくれています。

続いて記事 CRUD API です。

https://localhost:8000/api/articles

apiリスト.png

この画像は記事一覧 API の結果です。今回は記事がまだなかったのでからの JSON が出てきました。

DRF は URL と HTTP メソッドによって CRUD をします。

CRUD HTTP メソッド URL
Read ( 一覧 ) GET /api/articles
Read ( 詳細 ) GET /api/articles/{article.id}
Create POST /api/articles
Update ( 全フィールド ) PUT /api/articles/{article.id}
Update ( 一部フィールド ) PATCH /api/articles/{article.id}
DELETE DELETE /api/articles/{article.id}

※ {article.id} は記事の id です。

記事を投稿してみましょう。

記事の投稿には先程の記事一覧 API の画面からできます。

API のエントリーポイント的には POST で http://localhost:8000/api/articles

また、この記事作成 API はモデルに定義した通りのバリデーションも自動的にされます。

api投稿.png

投稿したら、投稿できたことが確認できます。

api投稿結果.png

ついでに記事詳細 API も見てみましょう。

https://localhost:8000/api/articles/1

api詳細.png

いかがでしたか ?
Django と DRF を使えばサクッと管理画面と API を実装できます。
本当に便利です。

DRF のシリアライザに一工夫したこと

シリアライザには create や update などの関数をオーバーライドしてカスタムできます。
また、field 自体もカスタムできます。

class ArticleSerialize(serializers.ModelSerializer):
    """
    記事シリアライザ
    """

    # 本来モデルには定義してないフィールド
    author = serializers.SerializerMethodField(read_only=True)

    # 作成時のみ使いたい宣伝フラグ
    is_advertising = serializers.BooleanField(write_only=True, default=False)

    class Meta:
        model = Article
        fields = ('title', 'body', 'author', 'is_advertising')

    # SerializerMethodField で定義したフィールドは get_XXX のように書く
    def get_author(self, obj):
        ...
        ...
        ...

    def create(self, validated_data):
        ...
        ...
        ...

    def update(self, instance, validated_data):
        ...
        ...
        ...

このようにカスタマイズできます。

しかし、作成時にしか使われない is_advertising が更新や読み取りと同じシリアライザに入ってしまっています。
もっというと実際に一つのシリアライザにいろいろな処理を詰め込むと結構な行数にもなります。

改良してみましょう。

class ArticleSerialize(serializers.ModelSerializer):
    """
    記事シリアライザ
    """

    # 本来モデルには定義してないフィールド
    author = serializers.SerializerMethodField(read_only=True)

    class Meta:
        model = Article
        fields = ('title', 'body', 'author')

    # SerializerMethodField で定義したフィールドは get_XXX のように書く
    def get_author(self, obj):
        ...
        ...
        ...


class CreateArticleSerialize(ArticleSerialize):
    """
    記事作成シリアライザ
    """

    # 作成時のみ使いたい宣伝フラグ
    is_advertising = serializers.BooleanField(write_only=True, default=False)

    class Meta(ArticleSerialize.Meta):
        fields = (*ArticleSerialize.Meta.fields, 'is_advertising')

    def create(self, validated_data):
        ...
        ...
        ...


class UpdateArticleSerialize(ArticleSerialize):
    """
    記事作成シリアライザ
    """

    class Meta(ArticleSerialize.Meta):
        pass

    def update(self, instance, validated_data):
        ...
        ...
        ...


class ArticleListSerializer(serializers.ModelSerializer):
    """
    記事一覧シリアライザ
    """

    class Meta:
      model = Article
      fields = ('title',)

こうすることで分離できました。
ポイントは 継承元のシリアライザ ( ArticleSerialize ) をアウトプット用と考えて実装すること です。
なぜ継承元のシリアライザがアウトプット用になるかというと、出力される JSON の構造を統一できるからです。
もちろん一覧と詳細では JSON の形式が全く違うことがあります。
そういったときは ArticleListSerializer のように ArticleSerialize を継承せずに定義します。
大切なことは アウトプットされる JSON の形式が何パターンあるか考えて設計する ことです。
フロントの人になるべく負担なく API を設計を心がけましょう。

DRF のシリアライザでデータを加工して保存や更新をする時の設計

Codespot では有料記事部分の文字数と画像数を出しています。

pay_count.png

有料部分の文字数と画像数は、記事を投稿してもらったタイミングで API サーバ側で計算して DB に保存しています。
この時、計算するタイミングは 2 つあります。
シリアライザの create とモデルの pre_save です。
pre_save を簡単に説明すると データを DB に保存する直前に処理を挟める 機能です。

結論から言うと Codespot では pre_save の方で計算処理をしています。
その理由は、シリアライザで処理をしてしまうとクリエイト用シリアライザ、アップデート用シリアライザ、管理画面で保存した時と複数の場所に同じ処理を行わなければなくなるからです。
pre_save で計算処理を実装することで、記事を保存・更新したときに必ず呼ばれるので計算処理を意識せず、クリエイト用、アップデート用シリアライザを実装できます。

Django には post_save というものもありますがこちらは データを DB に保存した直後に処理を挟める 機能です。
post_save を使ってしまうともしかしたら API レスポンスのタイミングによっては post_save で行った処理が反映されていないことがあるかもしれません。( 未確認 )

DRF のパーミッションにクセがある

DRF のパーミッションには少しクセがあります。
記事一覧 API には特にパーミッションはいらないけど記事詳細 API には欲しいときがあったとします。
また、記事詳細 API では ModelSerializer の標準以外の処理もしたいとします。
その時のソースコードが以下のようになるかと思います。

from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from articles.models import Article
from articles.serializers import ArticleSerialize


class ArticleViewSets(viewsets.ModelViewSet):
    """
    記事 Viewset
    """

    queryset = Article.objects.all()
    serializer_class = ArticleSerialize

    def retrieve(self, request, *args, **kwargs):
        # 独自の処理
        ...
        self.permission_classes = (IsAuthenticated, )
        return super().retrieve(request, *args, **kwargs)

しかし、これではパーミッションの処理がされません。
パーミッションの処理と retrieve の処理の順番の問題です。
そのため今回のような記事詳細 API だけパーミッションをつけたい & 独自処理もしたいというときはこうなります。

from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from articles.models import Article
from articles.serializers import ArticleSerialize


class ArticleViewSets(viewsets.ModelViewSet):
    """
    記事 Viewset
    """

    queryset = Article.objects.all()
    serializer_class = ArticleSerialize

    def get_permissions(self):
        permission_classes = self.permission_classes

        if self.action == 'retrieve':
            permission_classes = (IsAuthenticated, )

        return [permission() for permission in permission_classes]

    def retrieve(self, request, *args, **kwargs):
        # 独自の処理
        ...
        return super().retrieve(request, *args, **kwargs)

ちょっとキモいですね。
retrieve について書いてあるものが 2 箇所になってしまいました。
今の所私は仕様だと思ってこれで実装しています。
どなたか良い解決方法あれば教えてください。

me API のような REST でないものでも DRF に乗っかりたい時

me API とはフロントは認証できたけど自身のユーザ名とかわからないときに /api/me みたいなエンドポイントにリクエストしてユーザ名などを取得します。
ですが /api/me はどう見ても REST ではありません。
そんな時の実装例をです。

from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import api_view, permission_classes
from users.serializers import UserSerializer


@api_view(['GET'])
@permission_classes((IsAuthenticated,))
def me(request):
    """
    me API
    """
    user = request.user
    serializer = UserSerializer(user, context={'request': request})
    return Response(serializer.data)

モノリシックでない Web サービスでの OAuth と認証

Codespot の Oauth と Django の認証には django-allauthdjango-rest-framework-jwt を使っています。
いろいろ OAuth の方法を検討しましたが、少し違和感はありますが API サーバで OAuth しています。そのため API サーバですが JSON 以外のレスポンスもする API サーバになってしまいました。
OAuth でアカウントを管理して、認証に JWT を使うために API サーバとフロントで Cookie を用いて認証することにしました。
Cookie を API サーバとフロントで共有するにはドメインを一緒にしなければなりません。
API サーバのドメインを api.codespot.io、フロントを codespot.io に設定しています。
気をつけた点は技術記事販売プラットフォームなので必然的に不特定多数の人から投稿があります。
基本的には XSS の脆弱性は対応していますがどこかに XSS の脆弱性があると JavaScript で Cookie を読み取られてしまう可能性があります。Cookie を読み取られてしまうと JWT も読み取られてしまうのでアカウントが乗っ取られしまう危険性があります。
そのため Cookie には httponly にすることで JavaScript から Cookie を読み取れないようにしました。
しかし、JWT の内容を JavaScript で読み取れないと JWT 内のペイロードにある情報が読み取れません。
そこで Codespot では上述した me API を実装しています。

django-allauth のクセ

django-allauth にはクセ ? みたいなものがあり、何度もハマりました。
そのハマったポイントを共有できれば幸いです。

Codespost でやりたかったことが以下です。

  1. Twitter OAuthで認証してアカウントを作成
    • 必須ではないがメールアドレスを取得 ( 重要な通知があった際にユーザに不利益をなくすため )
    • ログインには Twitter OAuth でする。
  2. Stripe OAuth で Twitter OAuth で作ったアカウントに紐づける
    • 書き手用の売上振込に使用
    • 読み手は不要
  3. Cookie に JWT をセット
  4. 元々いたページにリダイレクト

躓いたポイントは

  • Twitter OAuth でメールアドレスの取得
  • 元々いたページにリダイレクト

の部分です。

Twitter OAuth でメールアドレスの取得

settings.py で以下のように django-allauth を設定します。
設定一覧はこちら

ACCOUNT_EMAIL_VERIFICATION = 'none'
ACCOUNT_EMAIL_REQUIRED = False

この設定でサインアップ時にメールアドレスは必須じゃなくなり、メールでの本人確認がなくなります。
しかし、このだけだと Twitter のメールアドレスを取得できません。
メールアドレスを取得するには以下の設定になります。

ACCOUNT_EMAIL_VERIFICATION = 'none'
ACCOUNT_EMAIL_REQUIRED = False
SOCIALACCOUNT_QUERY_EMAIL = True

SOCIALACCOUNT_QUERY_EMAIL の設定が増えました。
この設定で、OAuth する際に email のフィールドがある際に持ってきてくれるようになります。
Twitter だと OAuth して情報を取得する際に include_email クエリを含めて Twitter にリクエストしてくれるようになります。
また、動作している環境によって ACCOUNT_DEFAULT_HTTP_PROTOCOL を設定しましょう。

余談ですが email を取得する際には Twitter Developer Platform 内でアプリ側の設定も必要です。

元々いたページにリダイレクト

大体のサービスでもありますがログインしたあとはログインページの前にいたページに戻る機能があります。
以下が Codespot の OAuth 処理の手順になります。

  1. OAuth 用 view にリクエスト
    • この view は自作しています。
    • リクエストする際に next=https://codespot.io のように next クエリをつける
    • 受け取った next クエリパラメータを session に格納
  2. django-allauth 純正の OAuth 用 view にリダイレクト
    • この view は path('accounts/', include('allauth.urls')), のように urls.py で追加した django-allauth の view です。
    • django-allauth の機能で Twitter や Stripe の OAuth がされてアカウントが生成されたり紐付けられたりします。
    • リダイレクト先は settings.py の LOGIN_REDIRECT_URL になります。
  3. LOGIN_REDIRECT_URL の view にリダイレクト
    • この view は自作しています。
    • JWT をセット
    • session に入っている next の URL にリダイレクト
  4. next の URL にリダイレクトされる
    • フロント側から指定できる
    • Codespot では元々いたページを設定している。

簡単な図にするとこの様な感じです。

CodespotOAuth.png

具体的なソースコードについてはここでは一旦スキップしようと思います。( かなり長い内容になってしまうと思うのでもしかしたら別記事で出すかもしれません )
手を加えた箇所だけ上げておきます。

  • ACCOUNT_ADAPTER
  • SOCIALACCOUNT_ADAPTER

ここまで django-allauth でのリダイレクトについて書きましたが、実はデフォルトで next のクエリーが使えるはずだと思います…。
私には使いこなせませんでした。
どなたかわかる方いたらぜひ教えてください。

あとがき

いかがでしたでしょうか?役に立てたのであれば幸いです。
なかなか伝わりにくい部分もあると思うのでそういった方は SNS かこちらの記事にコメント下さい!
本当は開発環境、本番環境とかの話もしたいのですがまたの機会で書いてみようと思います。