drf-spectacularを使って、Django REST frameworkの実装からOpenAPI 3.0系のスキーマを生成する

これは Djangoのカレンダー | Advent Calendar 2022 - Qiita 12/13の記事です。

 
Django REST framework(DRF)でOpenAPI 3系のスキーマを生成しようと考えた時、DRFの公式ドキュメントでは

という選択肢が示されています。

 
前者については、Django Congress 2022のHonoShiraiさんの発表で詳しく紹介されていました。

 
一方、後者のサードパーティパッケージとしては drf-spectacular があります *1

drf-spectacular では主に

が行なえます。

前者の機能については akiyoko さんのBlogに詳しい解説があります。
Django REST Framework で API ドキュメンテーション機能を利用する方法(DRF 3.12 最新版) - akiyoko blog

後者の機能については、drf-spectacular の公式ドキュメントに充実した解説があります。
drf-spectacular — drf-spectacular documentation

 
ただ、後者の機能については実際に使ってみないと分からないところがありました。

そこで、試してみたときのメモを残します。

 
なお、差分をわかりやすくするよう、記事中では適宜コミットを示しています。

また、記事が長いため、記事ではソースコードを省略している部分が多々あります。もしソースコードの詳細を確認したい場合はGithubを参照ください。

 
目次

 

環境

  • WSL2
  • Python 3.10.9
  • Django 4.1.4
  • djangorestframework(DRF) 3.14.0
  • drf-spectacular 0.24.2
  • drf-nested-routers 0.93.4

 

環境構築

ベースとなるDRFアプリをセットアップする

次のようなModel・Serializer・ViewSet・URLconfを持つアプリをセットアップします。

なお、アプリの詳細についてはこのコミットで確認できます。

Model

class Shop(models.Model):
    name = models.CharField('名前', max_length=255)
    established_at = models.DateTimeField('設立日時', default=timezone.now)
    updated_at = models.DateTimeField(auto_now=True)

 
Serializer

class ShopSerializer(serializers.ModelSerializer):
    class Meta:
        model = Shop
        fields = ['id', 'name', 'established_at', 'updated_at']

 
ViewSet

class ShopViewSet(viewsets.ModelViewSet):
    queryset = Shop.objects.all()
    serializer_class = ShopSerializer

 
URLconf

app_name = 'api'

router = routers.SimpleRouter()
router.register('shops', viewset=views.ShopViewSet)

urlpatterns = [
    path('', include(router.urls)),
]

 
実装が終わったら、admin siteからデータを登録します。

最後に、curlで動作が確認できれば、ベースとなるアプリは完成です。

$ curl http://localhost:8000/api/shops/
[{"id":1,"name":"産直所","established_at":"2022-12-11T10:46:38+09:00","updated_at":"2022-12-11T10:46:46.237636+09:00"}]

 

drf-spectacularをセットアップする

公式ドキュメントに従い、 drf-spectacularをセットアップします。
Installation | drf-spectacular — drf-spectacular documentation

pip install drf-spectacular 後、settings.pyに INSTALLED_APPSREST_FRAMEWORK を設定します。

 

デフォルト設定で生成したOpenAPIスキーマを確認する

ここまでで準備ができたため、以下のコマンドでOpenAPIスキーマを生成します。

$ python manage.py spectacular --file openapi.yaml

 
生成された openapi.yaml ファイルの中身は以下の通りです(コミット)。

openapi: 3.0.3
info:
  title: ''
  version: 0.0.0
paths:
  /api/shops/:
    get:
      operationId: api_shops_list
      tags:
      - api
      security:
      - cookieAuth: []
      - basicAuth: []
      - {}
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Shop'
          description: ''
    post:
      operationId: api_shops_create
      tags:
      - api
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Shop'
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/Shop'
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/Shop'
        required: true
      security:
      - cookieAuth: []
      - basicAuth: []
      - {}
      responses:
        '201':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Shop'
          description: ''
  /api/shops/{id}/:
    get:
      operationId: api_shops_retrieve
      parameters:
      - in: path
        name: id
        schema:
          type: integer
        description: A unique integer value identifying this shop.
        required: true
      tags:
      - api
      security:
      - cookieAuth: []
      - basicAuth: []
      - {}
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Shop'
          description: ''
    put:
      operationId: api_shops_update
      parameters:
      - in: path
        name: id
        schema:
          type: integer
        description: A unique integer value identifying this shop.
        required: true
      tags:
      - api
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Shop'
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/Shop'
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/Shop'
        required: true
      security:
      - cookieAuth: []
      - basicAuth: []
      - {}
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Shop'
          description: ''
    patch:
      operationId: api_shops_partial_update
      parameters:
      - in: path
        name: id
        schema:
          type: integer
        description: A unique integer value identifying this shop.
        required: true
      tags:
      - api
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchedShop'
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/PatchedShop'
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/PatchedShop'
      security:
      - cookieAuth: []
      - basicAuth: []
      - {}
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Shop'
          description: ''
    delete:
      operationId: api_shops_destroy
      parameters:
      - in: path
        name: id
        schema:
          type: integer
        description: A unique integer value identifying this shop.
        required: true
      tags:
      - api
      security:
      - cookieAuth: []
      - basicAuth: []
      - {}
      responses:
        '204':
          description: No response body
components:
  schemas:
    PatchedShop:
      type: object
      properties:
        id:
          type: integer
          readOnly: true
        name:
          type: string
          title: 名前
          maxLength: 255
        established_at:
          type: string
          format: date-time
          title: 設立日時
        updated_at:
          type: string
          format: date-time
          readOnly: true
    Shop:
      type: object
      properties:
        id:
          type: integer
          readOnly: true
        name:
          type: string
          title: 名前
          maxLength: 255
        established_at:
          type: string
          format: date-time
          title: 設立日時
        updated_at:
          type: string
          format: date-time
          readOnly: true
      required:
      - id
      - name
      - updated_at
  securitySchemes:
    basicAuth:
      type: http
      scheme: basic
    cookieAuth:
      type: apiKey
      in: cookie
      name: sessionid

 

settings.pyによりカスタマイズする

デフォルトで生成されるOpenAPIスキーマをカスタマイズする場合、いくつかの方法があります。

まずは、settings.py の設定によるカスタマイズを記載します。

なお、ここでは自分が気になったものだけ記載しています。詳細は公式ドキュメントを参照してください。
Settings — drf-spectacular documentation

 

operationIdの値をcamelCaseにする

OpenAPIスキーマには operationId という項目があります。

Unique string used to identify the operation. The id MUST be unique among all operations described in the API. The operationId value is case-sensitive. Tools and libraries MAY use the operationId to uniquely identify an operation, therefore, it is RECOMMENDED to follow common programming naming conventions.

https://spec.openapis.org/oas/v3.0.3#operation-object

 
デフォルト設定で出力したスキーマを見ると、operationId は snake_case になっています。

paths:
  /api/shops/:
    get:
      operationId: api_shops_list

 
ただ、 operationId は camelCase のほうが望ましい場合もあります。

例えば、OpenAPI Generator の typescript-axios では、 opearationId を axios クライアントのメソッド名として利用するため、snake_caseだと違和感を生じるかもしれません。

 
そこで、公式ドキュメントの Settings ページを見てみると、 CAMELIZE_NAMES という設定がありました。

Camelize names like "operationId" and path parameter names Camelization of the operation schema itself requires the addition of 'drf_spectacular.contrib.djangorestframework_camel_case.camelize_serializer_fields' to POSTPROCESSING_HOOKS. Please note that the hook depends on djangorestframework_camel_case, while CAMELIZE_NAMES itself does not.

Settings — drf-spectacular documentation

 
デフォルトでは False になっています。

これを True へ設定変更してからOpenAPIスキーマを生成したところ、 operationId は camelCase (apiShopsList) になりました(コミット)。

 

PATCHメソッドのコンポーネントを他のメソッドと統一する

デフォルトの設定で生成されたスキーマを見たところ、PATCHメソッドだけ別のスキーマ PatchedShop になっていました。

違いは PatchedShop には required がないだけでした。

components:
  schemas:
    PatchedShop:
      type: object
      properties:
# ...
        updated_at:
          type: string
          format: date-time
          readOnly: true
    Shop:
      type: object
      properties:
# ...
        updated_at:
          type: string
          format: date-time
          readOnly: true
      required:
      - id
      - name
      - updated_at

 
公式ドキュメントを見ると

# Create separate components for PATCH endpoints (without required list)
'COMPONENT_SPLIT_PATCH': True,

[Settings — drf-spectacular documentation](https://drf-spectacular.readthedocs.io/en/latest/settings.html)

とあり、デフォルトでは True になっています。

これを 'COMPONENT_SPLIT_PATCH': False へ設定変更してからOpenAPIスキーマを生成したところ、 Shop スキーマに統一されました(コミット)。

 

OpenAPIのメタデータを設定する

OpenAPI 3.0.3 のスキーマには、 infoservers などのメタデータが存在します。
OpenAPI Specification v3.0.3 | Introduction, Definitions, & More

drf-spectacularの公式ドキュメントによると、それらのメタデータを設定するためには、settings.py に追加すれば良さそうでした。
Settings — drf-spectacular documentation

 
そこで settings.py に以下の設定を行います。

SPECTACULAR_SETTINGS = {
# ...
    # OpenAPI metadata
    'TITLE': 'DRF OpenAPI schema',
    'DESCRIPTION': 'OpenAPI Schema by drf-spectacular',
    'TOS': 'https://example.com/term',
    # Statically set schema version. May also be an empty string. When used together with
    # view versioning, will become '0.0.0 (v2)' for 'v2' versioned requests.
    # Set VERSION to None if only the request version should be rendered.
    'VERSION': '0.0.1',
    # Optional list of servers.
    # Each entry MUST contain "url", MAY contain "description", "variables"
    # e.g. [{'url': 'https://example.com/v1', 'description': 'Text'}, ...]
    'SERVERS': [{'url': 'http://localhost:8000/api/', 'description': '開発環境'}],
}

 
OpenAPIスキーマを生成したところ、以下のようなメタデータが設定されました(コミット)。

ちなみに、 settingsの TOStermsOfService に対する設定のようです。

info:
  title: DRF OpenAPI schema
  version: 0.0.1
  description: OpenAPI Schema by drf-spectacular
  termsOfService: https://example.com/term
#...
servers:
- url: http://localhost:8000/api/
  description: 開発環境

 

drf-spectacularの機能によりカスタマイズする

ただ、settings.pyでのカスタマイズには限界があります。

そこで、drf-spectacularの機能によるカスタマイズを記載します。

 

OpenAPIのenumを使う

OpenAPIでは enum にて、項目が取り得る値を指定できます。
Enums | Data Models (Schemas) | OpenAPI Guide | Swagger

 
そこで、DRFenumを定義すれば、drf-spectacularでOpenAPIスキーマへ反映できるかを確認してみます。

なお、DRFenumを定義するにはいくつか方法がありますが、今回は Enumeration typesIntegerChoices を使って定義してみます。
Enumeration types | モデルフィールドリファレンス | Django ドキュメント | Django

 
Shopモデルに IntegerChoices を継承したクラスを choices として追加します。

from django.utils.translation import gettext_lazy as _

class Shop(models.Model):
    class Size(models.IntegerChoices):
        SMALL = 1, _('小')
        MEDIUM = 2, _('中')
        LARGE = 3, _('大')

    size = models.IntegerField('規模', choices=Size.choices, default=Size.MEDIUM)

 
また、enumな項目をレスポンスするため、シリアライザの fields にも size を追加します。

class ShopSerializer(serializers.ModelSerializer):
    class Meta:
        model = Shop
        fields = ['id', 'name', 'size', 'established_at', 'updated_at']

 
OpenAPIスキーマを生成すると、Schemaに SizeEnum が追加されていました(コミット)。

# ...
        size:
          allOf:
          - $ref: '#/components/schemas/SizeEnum'
          title: 規模
# ...
    SizeEnum:
      enum:
      - 1
      - 2
      - 3
      type: integer

 

メソッドごとに operatoinId を定義する

上記の通り、 settings.pyへ設定することで、operationId の値を snake_case や camelCase に制御できました。

ただ、 アプリ名_モデル名_メソッド名 のようなデフォルトの命名規則ではなく、独自の命名規則で operationId を定義したいとします。

その場合、Viewに @extend_schema_view デコレータを付与し、 extend_schema の引数 operationId に希望する値を指定すれば良さそうでした。

If you want to annotate methods that are provided by the base classes of a view, you have nothing to attach @extend_schema to. In those instances you can use @extend_schema_view to conveniently annotate the default implementations.

Step 2: @extend_schema | Workflow & schema customization — drf-spectacular documentation

 
そこで、 list メソッドのみ operationIdshops を指定するよう定義してみます。

@extend_schema_view(
    list=extend_schema(
        operation_id='shops'
    )
)
class ShopViewSet(viewsets.ModelViewSet):
# ...

 
OpenAPIスキーマを生成すると、list メソッドのみ定義が変更されました(コミット)。

paths:
  /api/shops/:
    get:
      operationId: shops

 

OpenAPIの拡張項目を使う

OpenAPIのスキーマは、必要に応じて x- 始まりのキーを使って拡張することができます。

Extensions (also referred to as specification extensions or vendor extensions) are custom properties that start with x-, such as x-logo. They can be used to describe extra functionality that is not covered by the standard OpenAPI Specification.

OpenAPI Extensions

 
drf-spectacularでも拡張項目を定義できるか試してみます。

 

AWS API Gatewayの設定を追加する

AWS API GatewayではOpenAPIの定義を利用できます。また、そのOpenAPIスキーマは一部拡張されています。
HTTP API の OpenAPI 定義の使用 - Amazon API Gateway

今回は、この拡張定義を drf-spectacularでOpenAPIスキーマへ出力できるか試してみます。

drf-spectacularで拡張項目を設定するには、extend_schema の引数 extensions に、拡張項目用のdictを指定すれば良さそうです。
drf_spectacular.utils.extend_schema | Package overview — drf-spectacular documentation

そこで、 list メソッドのときだけ拡張項目を追加するようデコレータで定義してみます。

@extend_schema_view(
    list=extend_schema(
        extensions={
            'x-amazon-apigateway-integration': {
                'type': 'AWS_PROXY',
                'httpMethod': 'POST',
                'uri': 'arn:aws:lambda:***:***:function:HelloWorld',
                'payloadFormatVersion': '1.0'
            }
        }
    )
)
class ShopViewSet(viewsets.ModelViewSet):
# ...

 
OpenAPIスキーマを生成したところ、拡張項目の定義が追加されていました(コミット)

paths:
  /api/shops/:
    get:
# ...
      responses:
        # ...
      x-amazon-apigateway-integration:
        type: AWS_PROXY
        httpMethod: POST
        uri: arn:aws:lambda:***:***:function:HelloWorld
        payloadFormatVersion: '1.0'

 

enumに x-enum-varnames を追加する

「OpenAPIスキーマから各言語のクライアントを生成するためのツール」の1つに、OpenAPI Generatorがあります。
OpenAPITools/openapi-generator: OpenAPI Generator allows generation of API client libraries (SDK generation), server stubs, documentation and configuration automatically given an OpenAPI Spec (v2, v3)

OpenAPI Generator では、拡張項目 x-enum-varnames を使って enum をより便利に定義する機能があります。
Invalid enum var names are generated by multi-byte characters · Issue #893 · OpenAPITools/openapi-generator

例えば、 typescript-axios のクライアントを OpenAPI Generatorで生成した場合、x-enum-varnames が定義されていれば enumSize.0 ではなく Size.small で参照できるようになります。

 
ただ、先ほどの x-amazon-apigateway-integration と同じ書き方をしても、 enum と一緒には設定されません。

一緒に設定するには

  1. カスタムFieldを作成
  2. 作成したカスタムFieldに対し、 @extend_schema_field デコレータで定義

の流れで実装すれば良さそうでした。

@extend_schema_field({
    'type': 'integer',
    'title': Shop._meta.get_field('size').verbose_name,
    'enum': Shop.Size.values,
    'x-enum-varnames': Shop.Size.names,
})
class SizeField(serializers.IntegerField):
    pass


class ShopSerializer(serializers.ModelSerializer):
    size = SizeField(source='get_size_display')

    class Meta:
        model = Shop
        fields = ['id', 'name', 'size', 'established_at', 'updated_at']

 
この実装でOpenAPIスキーマを生成すると、 x-enum-varnames が設定できました。

ただ、enumスキーマとは別に定義されました。

size:
  allOf:
  - $ref: '#/components/schemas/SizeEnum'  # Enumの定義はこちら
  title: 規模
  x-enum-varnames:  # x-enum-varnames はこちら
  - SMALL
  - MEDIUM
  - LARGE

 
原因は POSTPROCESSING_HOOKS のデフォルト設定に drf_spectacular.hooks.postprocess_schema_enums があったためです。その結果、enumが別スキーマになったようです。

# Postprocessing functions that run at the end of schema generation.
# must satisfy interface result = hook(generator, request, public, result)
'POSTPROCESSING_HOOKS': [
    'drf_spectacular.hooks.postprocess_schema_enums'
],

# https://drf-spectacular.readthedocs.io/en/latest/settings.html

 
もし一緒に定義したい場合は、 POSTPROCESSING_HOOKS の設定を空配列に変更すれば良さそうです。

'POSTPROCESSING_HOOKS': []

 
OpenAPIスキーマを生成すると、 enumx-enum-varnames が同じ場所で定義されるようになりました(コミット)。

size:
  type: integer
  title: 規模
  enum:
  - 1
  - 2
  - 3
  x-enum-varnames:
  - SMALL
  - MEDIUM
  - LARGE

 

@actionによる追加エンドポイントを反映する

DRFでは、ViewSetのエンドポイントの他に独自のエンドポイントを追加したい場合、 @action デコレータを使って定義します。
Marking extra actions for routing | Viewsets - Django REST framework

 
例えば、以下のように実装すると /api/shops/message/ エンドポイントを増えます。また、このエンドポイントでは {'message': 'open'}JSONが返ってきます。

class ShopViewSet(viewsets.ModelViewSet):
    queryset = Shop.objects.all()
    serializer_class = ShopSerializer

    @action(detail=False, methods=['get'])
    def message(self, request, pk=None):
        return Response({'message': 'open'})

 
ただ、 この実装のままでOpenAPIスキーマを生成したとしても、レスポンスのスキーマ$ref: '#/components/schemas/Shop' となり、実際のレスポンスとは一致しません。

/api/shops/message/:
  get:
    operationId: apiShopsMessageRetrieve
    tags:
    - api
    security:
    - cookieAuth: []
    - basicAuth: []
    - {}
    responses:
      '200':
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Shop'
        description: ''

 
そこで、 @extend_schema_viewextend_schema を使って @action 分のResponseを定義します。

 
今回は以下のように定義します。

  • extend_schema_viewのキーワード引数は、エンドポイント名の message を使用
  • レスポンス型は、drf-spectacularの OpenApiResponseinline_serializer を使用
  • 例示は、 drf-spectacularの OpenApiExample を使用
@extend_schema_view(
    #...
    message=extend_schema(
        operation_id='shop_status',
        responses={
            200: OpenApiResponse(
                response=inline_serializer(
                    name='MessageResponse',
                    fields={
                        'message': serializers.CharField(),
                    },
                ),
                description='レスポンス型'
            )
        },
        examples=[OpenApiExample(
            name='例',
            value=[
                {'message': 'open'},
                {'message': 'close'}
            ]
        )],
        description='actionデコレータ分'
    )
)
class ShopViewSet(viewsets.ModelViewSet):
    queryset = Shop.objects.all()
    serializer_class = ShopSerializer

    @action(detail=False, methods=['get'])
    def message(self, request, pk=None):
        return Response({'message': 'open'})

 
OpenAPIスキーマを生成すると、追加した action のスキーマが適切に出力されていました(コミット)。

paths:
  /api/shops/message/:
    get:
      operationId: shopStatus
      description: actionデコレータ分
      tags:
      - api
      security:
      - cookieAuth: []
      - basicAuth: []
      - {}
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
              examples:
                :
                  value:
                  - message: open
                  - message: close
          description: レスポンス型
components:
  schemas:
    MessageResponse:
      type: object
      properties:
        message:
          type: string
      required:
      - message

 

drf-nested-routersによるネストしたルーティングを反映する

DRFでは、 /foo/{foo_id}/bar/{bar_id}/ のようなルーティングは、サードパーティパッケージを使うと容易に実装できます。

DRFの公式ドキュメントでもいくつか紹介されています。
Third Party Packages | Routers - Django REST framework

DRFの公式ドキュメントで紹介されているパッケージのうち、drf-nested-routersIncluded support for となっていました。
drf-spectacular — drf-spectacular documentation

そこで、 drf-nested-routers を使ったとき、どのようにOpenAPIスキーマへ反映されるかを確認してみます。

なお、 drf-nested-routers では

  • 一対多の関連を持つモデル
  • 多対多の関連を持つモデル

のルーティングを定義できるため、今回それぞれ実装してみます。

ちなみに、 drf-nested-routers の導入は、公式ドキュメントにある通り pip install するだけです。
alanjds/drf-nested-routers: Nested Routers for Django Rest Framework

$ pip install drf-nested-routers

 

一対多の関連を持つモデルの場合

Company : Shop = 1 : 多 になるような Company モデルを用意します。

なお、Companyモデルで default=1 としているように、Shopに対しては必ずCompanyが紐づくものとします。

class Company(models.Model):
    name = models.CharField('名前', max_length=255)

class Shop(models.Model):
    # ...
    updated_at = models.DateTimeField(auto_now=True)
    company = models.ForeignKey(Company, verbose_name='会社', on_delete=models.PROTECT, default=1)

 
CompanyとShopのデータはfixtureで用意します。

[
  {
    "model": "shop.company",
    "pk": 1,
    "fields": {
      "name": "あおり物産"
    }
  },
  {
    "model": "shop.company",
    "pk": 2,
    "fields": {
      "name": "シナノ商店"
    }
  },
  {
    "model": "shop.Shop",
    "pk": 1,
    "fields": {
      "name": "スーパー北紅",
      "company": 1
    }
  },
// ...
]

 
なお、 Shopモデルの updated_atauto_now=True を使っていることから、このままでは manage.py loaddata でエラーになってしまいます。

そこで、signalを使ってfixtureのときだけ自力で updated_at を設定できるような関数を用意します。

# models.py
def fix_updated_at_by_fixture(sender, instance, **kwargs):
    if kwargs['raw']:
        instance.updated_at = timezone.now()

pre_save.connect(fix_updated_at_by_fixture, sender=Shop)

 
モデルの準備ができたため、次はネストしたルーティングで使用するSerializerとViewSet、URLconfを用意します。

 
Serializer

NestedShopSerializer では、Shop モデルの情報のほか、項目 company でCompanyモデルの情報もレスポンスできるようにしています。

class CompanySerializer(serializers.ModelSerializer):
    class Meta:
        model = Shop
        fields = ['id', 'name']

class NestedShopSerializer(serializers.ModelSerializer):
    size = SizeField()
    company = CompanySerializer()

    class Meta:
        model = Shop
        fields = ['id', 'name', 'size', 'established_at', 'updated_at', 'company']

 
ViewSet

NestedShopViewSetでは、company_pk に紐づくShopのみに絞れるよう get_queryset を定義しています。

class CompanyViewSet(viewsets.ModelViewSet):
    queryset = Company.objects.all()
    serializer_class = CompanySerializer

class NestedShopViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
    serializer_class = NestedShopSerializer

    def get_queryset(self):
        return Shop.objects.filter(company=self.kwargs['company_pk'])

 
URLconf

drf-nested-routersSimpleRouterNestedSimpleRouter を使い、ルーティングを設定します。

from rest_framework_nested import routers as nest_routers

parent_router = nest_routers.SimpleRouter()
parent_router.register(r'companies', views.CompanyViewSet)

nest_shop_router = nest_routers.NestedSimpleRouter(parent_router, r'companies', lookup='company')
nest_shop_router.register(r'shops', views.NestedShopViewSet, basename='company-shops')

urlpatterns = [
# ...
    path(r'', include(nest_shop_router.urls)),
]

 
curlで動作確認してみると、問題なく動作していました。

$ curl http://localhost:8000/api/companies/1/shops/1/
{"id":1,"name":"スーパー北紅","size":2,"established_at":"2022-12-12T18:35:15.505077+09:00","updated_at":"2022-12-12T18:35:15.505109+09:00","company":{"id":1,"name":"あおり物産"}}

 
続いて、OpenAPIスキーマを生成したところ、ワーニングが出ました。

$ python manage.py spectacular --file openapi.yaml

Warning #0: NestedShopViewSet: could not derive type of path parameter "id" because it is untyped and obtaining queryset from the viewset failed. Consider adding a type to the path (e.g. <int:id>) or annotating the parameter type with @extend_schema. Defaulting to "string".

Schema generation summary:
Warnings: 1 (1 unique)
Errors:   0 (0 unique)

 
生成したOpenAPIスキーマを見たところ、path parameter idtypestring 型と、不正なスキーマになっています。

paths:
  /api/companies/{companyPk}/shops/{id}/:
    get:
      parameters:
      - in: path
        name: id
        schema:
          type: string
        required: true

 
そこで、 extend_schema_view で path parameter のスキーマを定義します。

@extend_schema_view(
    retrieve=extend_schema(
        parameters=[
            OpenApiParameter(name='company_pk', location=OpenApiParameter.PATH, type=int),
            OpenApiParameter(name='id', location=OpenApiParameter.PATH, type=int)
        ]
    ),
)
class NestedShopViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
    serializer_class = ShopSerializer

    def get_queryset(self):
        return Shop.objects.filter(company=self.kwargs['company_pk'])

 
再度、OpenAPIスキーマを生成したところ、適切なparameterになっていました(コミット)。

paths:
  /api/companies/{companyPk}/shops/{id}/:
    get:
      - in: path
        name: id
        schema:
          type: integer
        required: true

 
生成したスキーマの他の部分を見てみると、Serializerで指定した通り、shopの中にcompanyがネストして定義されていました。

components:
  schemas:
    Company:
      type: object
      properties:
        id:
          type: integer
          readOnly: true
        name:
          type: string
          title: 名前
          maxLength: 255
      required:
      - id
      - name
    # ...
    NestedShop:
      type: object
      properties:
# ...
        updated_at:
          type: string
          format: date-time
          readOnly: true
        company:
          $ref: '#/components/schemas/Company'
# ...

 

多対多の関連を持つモデルの場合

drf-nested-routers では、多対多の関連もサポートしています。
many to many relation? · Issue #69 · alanjds/drf-nested-routers

そこで、Shop : Apple = 多 : 多 になるような Apple モデルを用意します。

class Apple(models.Model):
    name = models.CharField('りんご名', max_length=255)
    shops = models.ManyToManyField(Shop, verbose_name='店')

 
また、fixtureも多対多のモデルにデータを投入できる内容へと修正します。
many to many - How do Django Fixtures handle ManyToManyFields? - Stack Overflow

[
// ...
  {
    "model": "shop.Apple",
    "pk": 1,
    "fields": {
      "name": "彩香",
      "shops": [1]
    }
  },
// ...
]

 
あとは、一対多と同様、Serializer・ViewSet・URLconf を修正します。

 
Serializer

一対多のときに使ったSerializerを shops として指定します。

また、多対多を扱えるようにするため、 many=True を指定します。
Nested relationships | Serializer relations - Django REST framework

class M2MAppleSerializer(serializers.ModelSerializer):
    shops = NestedShopSerializer(many=True)

    class Meta:
        model = Apple
        fields = ['id', 'name', 'shops']

 
ViewSet

get_querysetApple モデルの絞り込みを記載します。

なお、ShopとAppleは多対多の関係なので、 prefetch_related を使って絞り込むようにしています。
prefetch_related() | QuerySet API リファレンス | Django ドキュメント | Django

class M2MAppleViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
    serializer_class = M2MAppleSerializer

    def get_queryset(self):
        return Apple.objects.all().prefetch_related('shops').filter(id=self.kwargs.get('pk'))

 
URLconf

一対多のところで出てきた nest_shop_router を利用して、ルーティングを追加します。

m2m_router = nest_routers.NestedSimpleRouter(nest_shop_router, r'shops', lookup='shop')
m2m_router.register(r'apples', views.M2MAppleViewSet, basename='company-shops-apples')

urlpatterns = [
    path(r'', include(m2m_router.urls)),
]

 
curlで動作確認をしたところ、良さそうでした。

$ curl http://localhost:8000/api/companies/1/shops/1/apples/1/
{"id":1,"name":"彩香","shops":[{"id":1,"name":"スーパー北紅","size":2,"established_at":"2022-12-12T18:35:15.505077+09:00","updated_at":"2022-12-12T18:35:15.505109+09:00","company":{"id":1,"name":"あおり物産"}}]}

 
この状態でOpenAPIスキーマを生成したところ、一対多と同様、ワーニングが出ました。

$ python manage.py spectacular --file openapi.yaml
Warning #0: M2MAppleViewSet: could not derive type of path parameter "company_pk" because model "shop.models.Apple" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".
Warning #1: M2MAppleViewSet: could not derive type of path parameter "shop_pk" because model "shop.models.Apple" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".

Schema generation summary:
Warnings: 2 (2 unique)
Errors:   0 (0 unique)

 
そこで、ViewSetに @extend_schema_view を追加します。

その後OpenAPIを生成すると、ワーニングが消えて適切なスキーマとなりました(コミット)。

@extend_schema_view(
    retrieve=extend_schema(
        parameters=[
            OpenApiParameter(name='company_pk', location=OpenApiParameter.PATH, type=int),
            OpenApiParameter(name='shop_pk', location=OpenApiParameter.PATH, type=int)
        ]
    ),
)
class M2MAppleViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
# ...

 

例外に対するスキーマを追加する

デフォルトでdrf_spectacularが生成するOpenAPIスキーマには、4xxや5xxの例外系のレスポンス用スキーマが含まれていません。

ここで、 bad_request を使って常に400を返すViewSetを用意した時に 400なスキーマが追加されるかを試してみます。
Generic Error Views | Exceptions - Django REST framework

class ExceptionViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
    queryset = Company.objects.all()
    serializer_class = CompanySerializer

    def list(self, request, *args, **kwargs):
        return bad_request(request=request, exception=kwargs.get('exception'))

 
OpenAPIスキーマを生成してみると、レスポンスには200のものしかありませんでした。

/api/exceptions/:
  get:
# ...
    responses:
      '200':
        content:
          application/json:
            schema:
              type: array
              items:
                $ref: '#/components/schemas/Company'
# ...

 
そのため、OpenAPIスキーマに例外系を定義したい場合は、自分で実装する必要がありそうです。

自分で実装するときの参考になる情報としては以下がありました。

 
その中から、今回は OpenApiResponse を使った定義を試してみます。
drf_spectacular.utils.OpenApiResponse | Package overview — drf-spectacular documentation

class ExceptionViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
    queryset = Company.objects.all()
    serializer_class = CompanySerializer

    @extend_schema(
        responses={
            400: OpenApiResponse(
                response=inline_serializer(
                    name='ExceptionResponse',
                    fields={
                        'error': serializers.CharField(),
                    },
                ),
                description='Error Response Type'
            )
        },
    )
    def list(self, request, *args, **kwargs):
        return bad_request(request=request, exception=kwargs.get('exception'))

 
OpenAPIスキーマを生成したところ、400なレスポンスのスキーマが生成されました。

/api/exceptions/:
  get:
    operationId: exceptionsList
    tags:
    - exceptions
    security:
    - cookieAuth: []
    - basicAuth: []
    - {}
    responses:
      '400':
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExceptionResponse'
        description: Error Response Type
# ...
ExceptionResponse:
  type: object
  properties:
    error:
      type: string
  required:
  - error

 

その他

ここでは取り上げていない内容(例えば、ModelViewSet以外を使う時の実装)をOpenAPIスキーマに反映する場合も、 drf-spectacular の公式ドキュメントが参考になります。

また、FAQも充実しているため、迷ったら読むと良さそうです。
FAQ — drf-spectacular documentation

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/drf_spectacular_sample

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/drf_spectacular_sample/pull/1

*1:OpenAPI 2系の場合は、 drf-yasg を使うことになります。https://github.com/axnsan12/drf-yasg/