qtatsuの週報

初心者ですわぁ

DRFで例外が発生してからレスポンスを返すまでの処理をカスタムする

DRFには、例外(Exception)をResponseの形で返す仕組みが備わっています。その個人的なノートです。

やりたいこと

APIで発生したエラーは、最終的にResponseの形で、以下の例のように返されます。

GET /api/exc/10/?hakurei=reimu
HTTP 404 Not Found
Allow: GET, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "detail": "Not found."
}

(実用性はさておき)例えば、下の表示のように変更したいです 特に、フロント側でエラーの結果などやフラグを表示したい時、自由にカスタマイズできると助かると思います。

GET /api/exc/10/?hakurei=reimu
HTTP 404 Not Found
Allow: GET, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "detail": "Not found.",
    "status_code": 404,
    "patchouli": "ロイヤルフレア",
    "params": {
        "hakurei": "reimu"
    },
    "kwargs": {
        "pk": 10
    }
}

本記事での各種ファイル

  • サンプルアプリの、本記事に関係する部分のコードです。

  • api/urls.py

app_name = 'api'
urlpatterns = [
    path('exc/', ExceptionView.as_view(), name='exc'),
]
  • config/urls.py
urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),
]
class ExceptionView(APIView):
    @csrf_exempt
    def get(self, request, *args, **kwargs):
        from rest_framework.exceptions import NotFound
        raise NotFound

カスタム処理を挟むことができる場所

基本的に、エラーが発生してからレスポンスとして返すまでの流れは以下の通りです。変更できる箇所は、(1)~(3)に分類できると思います。

エラー(Viewの中) ➡︎ 例外をraise(2) ➡︎ Exception Handler(3) ➡︎ レスポンス

⬇︎(1) レスポンス

(1) エラーから直接レスポンスを作る

DRFの公式チュートリアル序盤でも出てきた方法です。 単純に、直接自分でレスポンスを書いてしまえば、どんな形でも返すことができます。

注意点として、Exceptionをraiseしていないため、Exceptionのハンドラ(後述)を経由する処理が適応されないという点があげられます。 また、serializerのバリデーションエラーなども自分で渡してあげる必要があります(下リンク参照)

class ExceptionView(APIView):
    @csrf_exempt
    def get(self, request, *args, **kwargs):
        return Response(data={'error': 'エラーです'}, status=404)
GET /api/exc/10/?hakurei=reimu
HTTP 404 Not Found
Allow: GET, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "error": "エラーです"
}

(2) 既存、もしくは作成した例外をraiseする

DRFは、APIViewを継承しているView(正確にはhandle_exceptionメソッドが実装されているView)内部で例外が発生すると、それをキャッチしてレスポンスを作成し、返してくれます。 例外は、APIExceptionを継承したもの、もしくはDjangoのHttp404 または PermissionDenid Exceptionが対象となります。

既存の例外

  • rest_framework.exception下にあります。

  • api/views.py

class ExceptionView(APIView):
    @csrf_exempt
    def get(self, request, *args, **kwargs):
        from rest_framework.exceptions import NotFound
        raise NotFound(detail='変更したメッセージです', code='変更したコードです')

detail引数を変更することで、メッセージを変更できます。デフォルトでも、例外の種類に応じたメッセージが表示可能です。

GET /api/exc/10/?hakurei=reimu
HTTP 404 Not Found
Allow: GET, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "detail": "変更したメッセージです"
}

カスタムの例外

APIExceptionクラスを継承し、以下のように3つのfieldを定義すればOKです。 https://www.django-rest-framework.org/api-guide/exceptions/#apiexception

class PatchouliUnavailable(APIException):
    status_code = 503
    default_detail = 'サーバーが物理的に焼け焦げています'
    default_code = 'service_unavailable_desune...'


class ExceptionView(APIView):
    @csrf_exempt
    def get(self, request, *args, **kwargs):
        raise PatchouliUnavailable

結果です。detailの内容と、2行目が503となっています。 default_codeについては、標準のハンドラ(後述)ではレスポンスに反映されません。

GET /api/exc/10/?hakurei=reimu
HTTP 503 Service Unavailable
Allow: GET, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "detail": "サーバーが物理的に焼け焦げています"
}

(3) Exception Handlerをカスタマイズする

DRFを使うなら、ViewSetやbilt-inのViewを使うと思います。そうすると、raiseされている例外がどこから発生しているかは、ソースコードを見なければわかりません。また、全ての例外➡︎レスポンスについて、情報を付加したいこともあると思います。 そのような場合には、例外➡︎レスポンスの変換をしているハンドラを変更することで対応できます。

rest_framework/views.pyにある、exception_handler関数が、例外➡︎レスポンス、の変換を最終的に行います。 exception_handler関数をオリジナル関数に差し替えることで、グローバルに変更を適応できます。

また、exception_handler関数を実際に呼んで利用している、get_exception_handler関数を各Viewで上書きすることで、個別のviewの挙動を変更できます。

https://www.django-rest-framework.org/api-guide/exceptions/#custom-exception-handling

  • オリジナルのハンドラの例です。ステータスコード、リクエストのGETパラメータ、URLにエンコードされたpk情報、任意の文字列をレスポンスのbody(response.dataに、dictとして渡せます)に含める処理を追加しています。
    • 表示には利用しませんが、exceptionオブジェクト自体を持たせておくと、テストの時などに便利です (exception_obj)
def patchouli_exception_handler(exc: Exception, context: dict) -> Response:
    # レスポンスヘッダをセットするなどの処理が必要なので、オリジナルのハンドラでレスポンスオブジェクトを一旦作ります。
    response = exception_handler(exc, context)

    if response is not None:
        # レスポンスのbodyに入れたいものは、data属性(dict)に入れる。
        response.data['status_code'] = response.status_code
        response.data['params'] = context['request'].query_params
        response.data['kwargs'] = context['kwargs']
        response.data['patchouli'] = 'ロイヤルフレア'
        # exceptionオブジェクト自体を持たせておくと、テストなどで便利です。
        response.exception_obj = exc
    return response

上記で書いたハンドラを、Viewにセットしていきます。1. globalに適応する、2. 個別のViewに適応する、の2通りあります。なお、ExceptionをResponseに変換するDRFの仕組みを利用するには、APIViewを継承している必要があります。なので、そのようなViewを使うか@api_viewデコレータを使ってviewを修飾しておく必要があります。

グローバルに設定する

作成したハンドラへのパスを書きます。ハンドラはどこにおいてもよいですが、app(今回は"api"という名前)の直下にutilsを作りました。

  • config/settings.py
REST_FRAMEWORK = {
    'EXCEPTION_HANDLER': 'api.utils.patchouli_exception_handler',
}

個別のViewに設定する

公式ドキュメントに記載はありませんでしたが、get_exception_handler関数を個別に上書きすることができます。またこの関数を呼んでいる、ラッパー関数のhandle_exceptionを上書きすればさらにいろんな修飾を加えられます(code not shown)。

(from-importの位置がよくないですが、利用箇所を明示するためにこのように書きました。)

class ExceptionView(APIView):
    @csrf_exempt
    def get(self, request, *args, **kwargs):
        from rest_framework.exceptions import NotFound
        raise NotFound

    def get_exception_handler(self):
        from api.utils import patchouli_exception_handler
        return patchouli_exception_handler

結果

GET /api/exc/10/?hakurei=reimu
HTTP 404 Not Found
Allow: GET, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "detail": "Not found.",
    "status_code": 404,
    "patchouli": "ロイヤルフレア",
    "params": {
        "hakurei": "reimu"
    },
    "kwargs": {
        "pk": 10
    }
}

そのほかの注意点

500エラー

DRFが用意しているAPIExceptionのどれにも該当しなかった場合、Djangoに処理をゆだねます(例外をraiseする)。Djangoの500エラーのテンプレートが利用されることになりますが、これをJSONレスポンスに変更することもできます。 https://www.django-rest-framework.org/api-guide/exceptions/#generic-error-views

configの直下のurl.pyに、以下を指定するだけです。

  • config/urls.py
urlpatterns = [
    path('api/', include('api.urls')),
    path('index/', TemplateView.as_view(template_name='index.html')),
]
handler500 = 'rest_framework.exceptions.server_error'

もともとcurlを叩くと以下のようでしたが

$ curl -X GET localhost:8000/api/exc/
<!DOCTYPE html>
<html lang="ja">
<head>
.....

上記のhandler500にserver_errorのviewを設定したことで、以下のようになります。

$ curl -X GET localhost:8000/api/exc/

{"error": "Server Error (500)"}

ただし、このようにするとこのproject全体の500がJSONレスポンスになってしまいます。 もし、apiと同一project内部で別のappにより、Djangoで通常のテンプレートを表示しているような場合にはContent-Typeを見て、処理のviewを分岐する必要があります。

どこに処理を挟むかのベストプラクティスがあれば、教えていただけると幸いです。個人的には、500エラーの際には上述したハンドラでresponseがNoneとなるので、それを見て特定のviewを返すのが良いと思っていますが...。

def course_500_exception_handler(exc: Exception, context: dict) -> Response:
    response = exception_handler(exc, context)

    if response is None:
        from rest_framework.exceptions import server_error
        return server_error(request=context['request'])

あとがき

ソースコードについても、あとでまとめようと思います。