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')), ]
- api/views.py
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
- api/views.py
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の位置がよくないですが、利用箇所を明示するためにこのように書きました。)
- api/views.py
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を返すのが良いと思っていますが...。
- api/utils.py
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'])
あとがき
ソースコードについても、あとでまとめようと思います。