エイエイレトリック

なぐりがき

django rest frameworkのschema自動生成の仕組みとカスタマイズ方法

Django REST framework (DRF) は Django で Web APIを構築するのに便利なパッケージです。 schema (スキーマ) を使えば OpenAPI (Swagger) のフォーマットでドキュメントを生成することができます。

この記事では、drfでどのようにスキーマが生成されるのか解説します。また、最後にスキーマの生成を自分でカスタマイズするユースケースも紹介します。

カスタマイズは魔改造な要素をふんだんに含んでいるので、こだわりがなければ最後の 3rd-party package の章で紹介しているものを使うことをおすすめします。

開発環境

  • Python 3.8
  • Django 3.2.3
  • djangorestframework 3.12.4
  • OpenAPI 3.0.2 (djangorestframeworkの設定に基づく)

OpenAPIの構成要素

OpenAPIはWeb APIを記述する仕様です。yamljson形式で記述します。 Swagger UI を使えば、ドキュメントとして可視化もできます。

Swagger Editor でサンプルファイルとドキュメントの出力を見ることができます。

サンプルファイルを例に、OpenAPIの構成要素を説明していきます。

f:id:sh111h:20210822102606j:plain
OpenAPI-Driven API Design

infoはタイトルやdescriptionのようなメタ情報が入ります。

info:
  title: Swagger Petstore
  description: 'This is a sample server Petstore server.'

securityでは認証設定を記述します。

security:
  - ApiKeyAuth: []
  - OAuth2:
      - read
      - write

pathsではエンドポイント (e.g. /pet, /store) の情報を記述します。 エンドポイントはHTTPメソッド (GET, POST, etc) ごと、Operation Objectとしてリクエストパラメーター (parameters) やレスポンス (responses) の定義ができます。

paths:
  /pet/{petId}:
    get:
      tags:
      - pet
      summary: Find pet by ID
      description: Returns a single pet
      operationId: getPetById
      parameters:
      - name: petId
        in: path
        description: ID of pet to return
        required: true
        schema:
          type: integer
          format: int64
      responses:
        200:
          description: successful operation
          content:
            application/xml:
              schema:
                $ref: '#/components/schemas/Pet'

componentsはpathsのレスポンスやリクエストのスキーマ定義であるcomponents objectを記述します。 例えば、上記の $ref: '#/components/schemas/Pet' はcomponentsの以下の部分を参照します。

components:
    Pet:
      required:
      - name
      - photoUrls
      type: object
      properties:
        id:
          type: integer
          format: int64
        category:
          $ref: '#/components/schemas/Category'
        name:
          type: string
          example: doggie
        photoUrls:
          type: array
          xml:
            name: photoUrl
            wrapped: true
          items:
            type: string

serversとtagsは今回扱わないので説明は省略します。 その他、詳しい仕様は Githubのドキュメント を参考にしてください。

APIをドキュメント化する方法

DRFでドキュメントを作る方法は Documenting your API - Django REST framework に記載されています。

UIとなるHTMLを設定し、 urls.pyTemplateView を使って表示させる方法です。

from django.views.generic import TemplateView
from rest_framework.schemas import get_schema_view

from .views import ConvertView

schema_view = get_schema_view() # 後述

urlpatterns = [
    # この `schema_view` 設定が表示される
    path("", schema_view, name="openapi-schema"),
    path(
        "swagger/",
        TemplateView.as_view(
            template_name="swagger-ui.html",
            extra_context={"schema_url": "openapi-schema"},
        )
        name="swagger-ui",
    ),
    path("convert/", ConvertView.as_view()),
]

TemplateView.as_view の引数 extra_context で指定している {"schema_url": "XXXX"} は同じ url_patterns に含まれている path (今回の場合 "openapi-schema")を指定します。

また、ConvertViewは今回ドキュメント化の例として用いるview関数です。

schema_view

TemplateViewから呼び出す schema_view はSchemaViewクラスである必要があります。

今回は SchemaView を簡単に設定できる get_schema_view を使います。 ドキュメントでも紹介されている SchemaView.as_view() を実行してくれる関数です。 (Generating a dynamic schema with SchemaView)

from rest_framework.schemas import get_schema_view

schema_view = get_schema_view(
    title="API Lists",
    url="/api/",
    description="apiの詳細",
    version="1.0.0"
)

OpenAPIのtitleやdescriptionのようなメタ情報はここで設定します。

ちなみに、OpenAPIとは別にcoreAPIもdrfには実装されており、特に指定しないとcoreAPIが使われます。(詳しくは後述のSchemaGeneratorで解説)

しかし、APIの中身、OpenAPIにおけるpathsやcomponentsはどこで設定できるのでしょうか? この設定をしただけではAPIの一覧が表示されるだけで、pathsのparameterやcomponentsのような詳細情報が表示されません。

公式ドキュメントではその後唐突にSchemaGeneratorやAutoSchemaの説明となってしまい、全体が掴みにくいです。 一度必要な情報を整理した後、parameterの設定をするにはどうすればいいか、解説していきます。

Schema関連クラスの関係

API全体のメタ情報を get_schema_view で設定しましたが、実際にメタ情報を扱うのは SchemaGenerator です。 SchemaGenerator はメタ情報と pathごとの情報を集約し、スキーマを生成します。

pathごとの情報はviewクラス (APIView) に依存しています。 Viewクラスに紐づいたAutoSchemaクラスのスキーマがparameterやresponseといった情報を生成します。

図にすると以下の通りです。

f:id:sh111h:20210822102923j:plain
schema関連のクラス

ボトムアップに生成される順番を列挙すると

  • viewクラスの要素AutoSchemaによって viewのpaths (parameters, response, etc.)、componentsを定義し辞書を生成する
  • SchemaGeneratorがviewごとの辞書を集約し、メタ情報を含め一つの辞書にする
  • SchemaViewでrendering

という流れになります。以降、SchemaGeneratorとAutoSchemaの役割について説明していきます。

SchemaGenerator

SchemaGeneratorはschema情報をまとめ、生成するクラスです。

get_schema_viewにおいて引数 generator_class で指定できるクラスなのですが、generator_class を渡さないと coreapi.SchemaGenerator が指定されます。 openAPIを使いたい場合は openapi.SchemaGenerator を指定しましょう。

メインの実行は get_schema 関数で、ドキュメントに表示するスキーマ情報を生成します。 get_schema_view で渡される title, description などは info object として一つの要素に格納します。

ドキュメントで重要となる情報pathsとcomponentsはviewクラスごと生成します。 どのviewクラスを生成の対象にするかは get_schema_view の引数 url_conf で指定できます。

デフォルトのSchemaGeneratorで生成されるスキーマのobjectは openapi, info, paths, components だけなので、 security, servers なども組み込みたい場合はカスタマイズが必要です。

AutoSchema

viewクラスごと設定するpathsとcomponentsはAutoSchemaによって生成されます。

AutoSchemaは settingsDEFAULT_SCHEMA_CLASS か、以下のようにviewクラスごとで指定できます。

from rest_framework.views import APIView

class ConvertView(APIView):
    # (中略)
    schema = AutoSchema()

AutoSchemaがスキーマ情報を生成するメインの関数は get_componentsget_operation です。

get_operation ではpathsの情報を生成しSchemaGeneratorに渡します。 具体的にはviewの path, methodごとparametersやresponseのような operation object の要素を定義し、辞書で返します。それぞれの定義は get_XXX 関数を使って生成します。

例えばConvertというviewクラスで、get, postの2つメソッドがある場合、以下のようにスキーマ情報が格納されます。

  • path["convert"]["get"] = self.get_operation("convert", "get")
  • path["convert"]["post"] = self.get_operation("convert", "post")

具体的な要素の指定方法を簡単に説明します。

  • descriptionはmethodのdocstring (__doc__)から取得します。markdownに対応しているので箇条書きや太字表記も可能です。
  • parametersはviewクラスのquery,setmodel,pagination_class,filter_backendsの出力を利用します。
  • requestBodyはviewクラスのserializerからcomponents objectのパスを$ref: ~ の形で指定します。 (実際にcomponents objectを生成するのは get_components)
  • responsesも同様に、viewクラスのserializerからcomponents objectのパスを指定します。

get_components では components object の情報を生成しSchemaGeneratorに渡します。 主にはviewクラスが定義した serializerクラスに基づいて schema object を定義します。 このcomponentsは get_operation の実行の際、requestBodyやresponsesなどで指定したパスと一致する仕組みになっています。

ここで、viewクラスがserializerと紐づいていないとcomponentsではなにも生成されず、スキーマ情報がほぼなくなってしまう問題が発生します。

schemaのカスタマイズ

ここまで、schemaの仕組みについて簡単に説明しました。 デフォルトのクラスをそのまま利用すると不便な点もあるため、継承した独自クラスを作ります。

SchemaGeneratorでsecurity設定をする

前述したようにデフォルトのSchemaGeneratorではsecurityを扱えません。 OpenAPIで Authorize ボタンを使えるよう、basic認証を追加してみます。

ドキュメントによると、 basic認証はcomponentsに "securitySchemes"の設定を追加し、 security objectでも指定することでAPI全体に適用できます。

そのため、 get_schema 関数内で生成される schema 辞書にそれらの設定を追加すれば良いです。

from rest_framework.schemas.openapi import SchemaGenerator

class CustomSchemaGenerator(SchemaGenerator):
    def get_schema(self, request=None, public=False):
        schema = super().get_schema(request, public)
        # ここから追記
        if schema:
            if "components" in schema and "securitySchemes" not in schema["components"]:
                schema["components"]["securitySchemes"] = {
                    "basicAuth": {
                        "type": "http",
                        "scheme": "basic",
                        "description": "basic authentication",
                    }
                }
            if "security" not in schema:
                schema["security"] = [{"basicAuth": []}]
        return schema

これでドキュメント表示の際にAuthorizeが表示されるようになります。 basic認証以外の認証も同様の方法で追加できます。

f:id:sh111h:20210822103008j:plain
authorization

AutoSchemaでfilter_backendsを使ってserializerを元にparametersを設定する

記事の最初で、なにも設定しないとparametersが表示されないことがあると説明しました。 前述の通り、AutoSchemaがparametersを生成するには、viewクラスのquery,setmodel,pagination_class,filter_backendsが必要です。

querysetやmodelを使っていれば問題ないですが、serializerでrequestのデータを取得している場合、いずれにも該当せず表示されません。

今回は Django SwaggerにQuery stringの項目も出したい! の記事を参考に、 filter_backends を使って serialzer からパラメーターを生成する方法を紹介します。

AutoSchemaの get_filter_parameters の実装をみると、filter_backends.get_schema_operation_parameters でparametersを取得しているので、これに則ったfilter_backendsクラスを自作します。

get_schema_operation_parameters で返す要素はopenapiのドキュメント (Parameter Object) に従います。

serializerの XXXField の型をopenapi用に変換する get_type_ も実装しました。 (今回はstring, integer, booleanだけ設定してます)

from rest_framework.fields import BooleanField, CharField, IntegerField
from rest_framework.filters import BaseFilterBackend

class SwaggerFilterBackend(BaseFilterBackend):
    @staticmethod
    def get_type_(value):
        if type(value) == CharField:
            type_ = "string"
        elif type(value) == IntegerField:
            type_ = "integer"
        elif type(value) == BooleanField:
            type_ = "boolean"
        else:
            type_ = "string"
        return type_

    def get_schema_operation_parameters(self, view):
        """for openapi"""
        fields = []
        for key, value in view.serializer_class._declared_fields.items():
            type_ = self.get_type_(value)
            field = {
                "name": key,
                "required": value.required,
                "in": "query",
                "description": value.help_text,
                "schema": {"type": type_},
            }
            fields.append(field)
        return fields

このクラスを view にセットするか、settingsの DEFAULT_FILTER_BACKENDS でデフォルトに指定することで parametersの生成時に使うことができます。

例えば、以下のようにserializerとviewクラスを設定すると下記の画像のようなparameterが表示されます

serializers.py

from rest_framework import serializers

class ConvertRequestSerializer(serializers.Serializer):
    text: serializers.CharField = serializers.CharField(
        required=False, help_text="変換したい書き言葉の文字列"
    )
    date_replace: serializers.BooleanField = serializers.BooleanField(
        default=False, help_text="日付変換オプション"
    )

view.py

from rest_framework.views import APIView
from . import serializers
from .schema_utils import CustomSchema

class ConvertView(APIView):

    http_method_names = ["get"]
    serializer_class = serializers.ConvertRequestSerializer
    schema = CustomSchema()

    def get(self, request):
        """文字列の変換"""

実際の表示

f:id:sh111h:20210822103146j:plain
parametersの表示

AutoSchemaでserializerを使わずresponseを設定する

serializerからrequestに必要なparametersを設定しました。

基本的に、viewクラスにはserializerクラスは1つしか設定できません。 requestで使ったserializerとは異なる定義でresponseを設定したい場合、serializer以外を使うしかありません。

AutoSchemaではserializerを使ってresponseを設定しているため、responseの取得を別の方法で設定できるようなカスタマイズしたAutoSchemaクラスも必要です。

responseも自動で生成したいところですが、今回作成したAPIはModelViewを使っていおらず、返却のスキーマを細かく決めてませんでした。 妥協策として、ドキュメントのresponse部分をコード上に書き、それをAutoSchemaに読み込ませる実装にしました。

現在はアーカイブされている django-rest-swaggerYAMLDocstringParser を参考に、viewクラスのドキュメントからyamlを取得する方法で response を設定します。

get_response から呼び出す関数 get_yaml_from_docstring をつくりました。 クラスのメソッドことのドキュメント __doc__ から、yamlの該当部分だけ読み込みます。 --- を起点としているのは、drf--- 以降をopenapiのドキュメント (operation objectのdescription) として読み込まない仕様のためです。

from rest_framework.schemas.openapi import AutoSchema

class CustomSchema(AutoSchema):
    def get_yaml_from_docstring(self, method):
        """docstringからyamlを取得
        `---` を起点として読み込む
        """
        view = self.view
        method_name = str(method).lower()
        if not hasattr(view, method_name):
            return None

        docstring = getattr(view, method_name).__doc__
        split_lines = cleandoc(docstring).split("\n")
        # Cut YAML from rest of docstring
        for index, line in enumerate(split_lines):
            line = line.strip()
            if line.startswith("---"):
                cut_from = index
                break
        else:
            return None
        yaml_string = "\n".join(split_lines[cut_from:])
        yaml_string = formatting.dedent(yaml_string)
        try:
            return yaml.load(yaml_string, Loader=yaml.SafeLoader)
        except yaml.YAMLError:
            return None

実際のviewクラスのドキュメントは以下のようにyamlを記述しています。

from rest_framework.views import APIView
from . import serializers
from .schema_utils import CustomSchema


class ConvertView(APIView):

    http_method_names = ["get"]
    serializer_class = serializers.ConvertRequestSerializer
    schema = CustomSchema()

    def get(self, request):
        """文字列の変換
        ほげほげほげほげ
        ---
        responses:
            schema:
                type: object
                properties:
                    conv_text:
                        description: '変換テキスト'
                        type: object
                        properties:
                            field:
                                description: 'kiji field or text'
                                type: string
                    orig_text:
                        descrption: '元テキスト'
                        type: object
                        properties:
                            field:
                                description: 'kiji field or text'
                                type: string
        """
        serializer = serializers.ConvertRequestSerializer(data=request.GET.copy())

get_yaml_from_docstring を呼び出すよう、get_response を修正します。 ややこしいのですが、 "schema" 部分だけ欲しいので、yamlの中の response->schema だけ渡します。

また、取得したスキーマself._get_reference(serializer) の部分でcomponentsとして登録する ({'$ref': '#/components/schemas/responseSchema'} のかたちになる) ので、get_components も合わせて修正します。

具体的には、 get_responseself.yaml_response を保存しておき、 get_components でこれをcomponentsとして追加します。 しない場合、登録したcomponentsは反映されません。

class CustomSchema(AutoSchema):
    def get_responses(self, path, method):
        if method == "DELETE":
            return {"204": {"description": ""}}

        self.response_media_types = self.map_renderers(path, method)

        serializer = self.get_serializer(path, method)

        if isinstance(serializer, serializers.Serializer):
            item_schema = self._get_reference(serializer)
        else:
            # ここから追記
            yaml_res = self.get_yaml_from_docstring(method)
            if yaml_res and yaml_res.get("responses"):
                self.yaml_response = yaml_res["responses"].get("schema")
                item_schema = self._get_reference(serializer)
            else:
                item_schema = {}
            # 追記ここまで
        if is_list_view(path, method, self.view) and not self.yaml_response:
            response_schema = {"type": "array", "items": item_schema}
        else:
            response_schema = item_schema
        status_code = "201" if method == "POST" else "200"
        return {
            status_code: {
                    "content": {
                        ct: {"schema": response_schema} for ct in self.response_media_types
                    },
            }
    
    def get_components(self, path, method):
        if method.lower() == "delete":
            return {}

        serializer = self.get_serializer(path, method)
        if isinstance(serializer, serializers.Serializer):
            component_name = self.get_component_name(serializer)
            content = self.map_serializer(serializer)
            return {component_name: content}
        # ここから追記
        elif self.yaml_response:
            component_name = self.get_component_name(serializer)
            return {component_name: self.yaml_response}
        # 追記ここまで
        return {}

実行すると以下のような表示になります

f:id:sh111h:20210822103211j:plain
responseとcomponents

ちなみに、 AutoSchemaの get_responses の実装では1つの Response Object しか設定できません。

400番台、500番台のようにcontentが必要ないのであれば以下のように追加することも可能です。

        return {
            status_code: {
                "content": {
                    ct: {"schema": response_schema} for ct in self.response_media_types
                },
            },
            "404": {"description": "Not Found", "content": {}}
        }

おまけ:3rd-party package

独自にカスタマイズする方法をいくつか紹介しましたが、3rd-party packageを使うという選択肢もあります。

drfのドキュメント では drf-yasg が最初に紹介されていますが、対応しているOpenAPIがver2.0と古いため、現状 drf-spectacular を利用するのが最適です。

drf-spectacular では、@extend_schema を使ってメソッドごとパラメーターを記述できます。 paramter・responseなどの要素に対しそれぞれ別のserializerを指定することが可能です。

responseのカスタマイズで紹介したdjango-rest-swaggerアーカイブされていますが、カスタマイズを実装する上で参考になるコードが多くあります。

OpenAPIもdrfも更新されていますので、これらのパッケージが対応しているか確認してから使いましょう。