これは 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_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.
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
公式ドキュメントを見ると
'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 = {
'TITLE': 'DRF OpenAPI schema',
'DESCRIPTION': 'OpenAPI Schema by drf-spectacular',
'TOS': 'https://example.com/term',
'VERSION': '0.0.1',
'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
そこで、 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.
OpenAPI Extensions
drf-spectacularでも拡張項目を定義できるか試してみます。
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
デコレータで定義
の流れで実装すれば良さそうでした。
@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'
title: 規模
x-enum-varnames:
- SMALL
- MEDIUM
- LARGE
原因は POSTPROCESSING_HOOKS
のデフォルト設定に drf_spectacular.hooks.postprocess_schema_enums
があったためです。その結果、enumが別スキーマになったようです。
'POSTPROCESSING_HOOKS': [
'drf_spectacular.hooks.postprocess_schema_enums'
],
もし一緒に定義したい場合は、 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を定義します。
今回は以下のように定義します。
- 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
を設定できるような関数を用意します。
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スキーマに例外系を定義したい場合は、自分で実装する必要がありそうです。
自分で実装するときの参考になる情報としては以下がありました。
その中から、今回は 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