これは Djangoのカレンダー | Advent Calendar 2022 - Qiita 12/13の記事です。
Django REST framework(DRF)でOpenAPI 3系のスキーマを生成しようと考えた時、DRFの公式ドキュメントでは
という選択肢が示されています。
前者については、Django Congress 2022のHonoShiraiさんの発表で詳しく紹介されていました。
- DjangoCongress JP 2022
- OAI3を使った Django REST frameworkの ドキュメント生成とカスタマイズ / DjangoCongress JP 2022 - Speaker Deck
一方、後者のサードパーティパッケージとしては drf-spectacular
があります *1。
drf-spectacular
では主に
- APIのドキュメンテーションを表示 (Swagger UIやRDoc)
- OpenAPI 3.0系のスキーマを生成
が行なえます。
前者の機能については akiyoko さんのBlogに詳しい解説があります。
Django REST Framework で API ドキュメンテーション機能を利用する方法(DRF 3.12 最新版) - akiyoko blog
後者の機能については、drf-spectacular
の公式ドキュメントに充実した解説があります。
drf-spectacular — drf-spectacular documentation
ただ、後者の機能については実際に使ってみないと分からないところがありました。
そこで、試してみたときのメモを残します。
なお、差分をわかりやすくするよう、記事中では適宜コミットを示しています。
また、記事が長いため、記事ではソースコードを省略している部分が多々あります。もしソースコードの詳細を確認したい場合はGithubを参照ください。
- https://github.com/thinkAmi-sandbox/drf_spectacular_sample
- https://github.com/thinkAmi-sandbox/drf_spectacular_sample/pull/1
目次
- 環境
- 環境構築
- デフォルト設定で生成したOpenAPIスキーマを確認する
- settings.pyによりカスタマイズする
- drf-spectacularの機能によりカスタマイズする
- その他
- ソースコード
環境
- 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_APPS
と REST_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.
デフォルト設定で出力したスキーマを見ると、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.
デフォルトでは 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 のスキーマには、 info
や servers
などのメタデータが存在します。
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の TOS
は termsOfService
に対する設定のようです。
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
そこで、DRFでenumを定義すれば、drf-spectacularでOpenAPIスキーマへ反映できるかを確認してみます。
なお、DRFでenumを定義するにはいくつか方法がありますが、今回は Enumeration types
の IntegerChoices
を使って定義してみます。
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
- drf_spectacular.utils.extend_schema_view | Package overview — drf-spectacular documentation
- drf_spectacular.utils.extend_schema | Package overview — drf-spectacular documentation
そこで、 list
メソッドのみ operationId
に shops
を指定するよう定義してみます。
@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.
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
が定義されていれば enumは Size.0
ではなく Size.small
で参照できるようになります。
ただ、先ほどの x-amazon-apigateway-integration
と同じ書き方をしても、 enum と一緒には設定されません。
一緒に設定するには
- カスタムFieldを作成
- 作成したカスタムFieldに対し、
@extend_schema_field
デコレータで定義
の流れで実装すれば良さそうでした。
- django rest framework - Using extend_schema_field for custom field - Stack Overflow
- drf_spectacular.utils.extend_schema_serializer | Package overview — drf-spectacular documentation
@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
が設定できました。
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スキーマを生成すると、 enum
と x-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_view
と extend_schema
を使って @action
分のResponseを定義します。
- python 3.x - drf-spectacular: add response description - Stack Overflow
- drf_spectacular.utils.inline_serializer | Package overview — drf-spectacular documentation
- Define component schema with drf-spectacular for django API - Stack Overflow
今回は以下のように定義します。
- extend_schema_viewのキーワード引数は、エンドポイント名の
message
を使用 - レスポンス型は、drf-spectacularの
OpenApiResponse
とinline_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-routers
が Included 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_at
が auto_now=True
を使っていることから、このままでは manage.py loaddata
でエラーになってしまいます。
そこで、signalを使ってfixtureのときだけ自力で updated_at
を設定できるような関数を用意します。
- django - Is DateTimeField with auto_now_add option enabled must has value in fixtures - Stack Overflow
- What's a "fixture"? | django-admin と manage.py | Django ドキュメント | Django
# 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-routers
の SimpleRouter
や NestedSimpleRouter
を使い、ルーティングを設定します。
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 id
の type
が string
型と、不正なスキーマになっています。
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_queryset
に Apple モデルの絞り込みを記載します。
なお、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スキーマに例外系を定義したい場合は、自分で実装する必要がありそうです。
自分で実装するときの参考になる情報としては以下がありました。
- Returning different types of 400 exceptions · Issue #101 · tfranzel/drf-spectacular
- python 3.x - drf-spectacular: add response description - Stack Overflow
その中から、今回は 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/