앗! 광고가 차단되었어요!

글 내용이 방문자께 도움이 되었다면, 광고 차단 프로그램 해제를 고려해주세요 😀.

공돌이

문서화를 위한 drf-yasg 적용하기

this-gpa 2020. 10. 26. 16:21

drf-yasg는 django-rest-framework으로 정의한 API를 문서화할 수 있는 패키지입니다.

drf-yasg의 Repository에서는 다음과 같이 소개하고 있습니다.

 

drf-yasg - Yet another Swagger generator

Generate real Swagger/OpenAPI 2.0 specifications from a Django Rest Framework API.

 

drf-yasg를 단순히 적용만 해도 정의한 모델, API 목록을 볼 수 있는 문서를 생성할 수 있으며, 필요에 따라 개발자가 내용을 추가하거나 수정할 수 있습니다.

다만 처음 적용할 때라면 (제 기준에서) 적용 후기가 별로 없고, 패키지 문서화도 조금은 부족한 느낌이라서 헤맬 수도 있을 것 같습니다.

이 문서에서는 간단하게 drf-yasg 적용방법과 쿼리 파라미터, 응답에 대한 문서화 방법을 다루겠습니다.

제가 이해한대로 작성한 것이니 다르거나 틀린 부분이 있을 수도 있습니다. 또 패키지 성격상 적용하시는 개발자/팀의 컨벤션에 따라 자유롭게 사용할 수 있을 것 같습니다.

시작하기 전, 예제 프로젝트 설명

저는 예제 django project mysite를 만들고, 내부에 app myapp을 생성했습니다.

  • 프로젝트의 뷰셋들은 drf의 Router에 등록되어 있습니다.
  • 프로젝트에는 drf의 LimitOffsetPagination이 적용된 상태입니다.
  • 아래는 모델과 뷰셋에 대한 코드입니다.

models.py :

from django.db import models


class TestModel1(models.Model):
    type_str = models.CharField(max_length=100)
    type_int = models.IntegerField()
    type_bool = models.BooleanField()


class TestModel2(models.Model):
    type_str = models.CharField(max_length=100)
    type_for = models.ForeignKey(
        'TestModel1',
        on_delete=models.CASCADE
    )


from rest_framework import serializers


class TestModel1Serializer(serializers.ModelSerializer):
    class Meta:
        model = TestModel1
        fields = '__all__'


class TestModel2Serializer(serializers.ModelSerializer):
    class Meta:
        model = TestModel2
        fields = '__all__'

views.py :

from rest_framework import viewsets
from rest_framework.response import Response

from .models import TestModel1, TestModel2, TestModel1Serializer, TestModel2Serializer


class TestModel1ViewSet(viewsets.ModelViewSet):
    queryset = TestModel1.objects.all()
    serializer_class = TestModel1Serializer

    def list(self, request, *args, **kwargs):
        param_hello = request.query_params.get('hello')
        if param_hello:
            # do something with hello...
            queryset = TestModel1.objects.filter(type_int__gt=1)
            queryset = self.paginate_queryset(queryset)
            serializer = self.get_serializer(queryset, many=True)
            return self.get_paginated_response(serializer.data)
        return super().list(request, *args, **kwargs)


class TestModel2ViewSet(viewsets.ModelViewSet):
    queryset = TestModel2.objects.all()
    serializer_class = TestModel2Serializer

    def list(self, request, *args, **kwargs):
        param_world = request.query_params.get('world')

        if param_world:
            # do something with world...
            return Response({
                'error': 'eeeeeeror!',
                'detail': 'something bad happened',
                'code': 10
            }, status=500)
        return super().list(request, *args, **kwargs)

패키지 설치 및 적용

그러면 본격적으로 drf-yasg를 적용해보도록 하겠습니다.

먼저 패키지를 설치합니다.

(venv) $ pip install drf-yasg

그리고 settings.pyINSTALLED_APPS에 drf-yasg을 추가합니다.

INSTALLED_APPS = [
    ...
    'rest_framework',
    'myapp',
    # drf_yasg를 APPS에 추가
    'drf_yasg'
]

swagger 엔드포인트 추가

이제 swagger 문서를 볼 수 있는 엔드포인트를 추가해야 합니다.

get_schema_view()를 통해 문서 뷰를 가져오고 url을 등록합니다.

In urls.py :

...

from django.conf import settings
from django.urls import path, include, re_path

from rest_framework import routers, permissions
from drf_yasg.views import get_schema_view
from drf_yasg import openapi

...

# swagger 정보 설정, 관련 엔드포인트 추가
# swagger 엔드포인트는 DEBUG Mode에서만 노출
schema_view = get_schema_view(
    openapi.Info(
        title="Snippets API",
        default_version='v1',
        description="Test description",
        terms_of_service="https://www.google.com/policies/terms/",
        contact=openapi.Contact(email="contact@snippets.local"),
        license=openapi.License(name="BSD License"),
    ),
    public=True,
    permission_classes=(permissions.AllowAny,),
)

if settings.DEBUG:
    urlpatterns += [
        re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
        re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
        re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc')
    ]

적용이 끝났습니다. 서버를 실행 후 /swagger/에 접속하면 다음 화면이 나타납니다.

 

문서를 둘러보면 모델의 정의, API 리스트가 잘 나오는 것을 볼 수 있습니다.

ViewSet의 docstring

정의한 뷰셋에 docstring을 추가하면 swagger 문서에서도 나타납니다. Markdown 문법을 지원한다고 합니다.

주의할 점은 ViewSet의 docstring을 사용하면, CRUD 모든 엔드포인트의 윗부분에 추가된다는 점입니다. 이 윗부분을 일반적으로 summary라고 하는 것 같습니다.

In views.py :

class TestModel1ViewSet(viewsets.ModelViewSet):
    """
    Model1의 CRUD
    ---
    Hello, World
    """
    queryset = TestModel1.objects.all()
    serializer_class = TestModel1Serializer

    ...

결과 :

추가 문서화 (Custom Schema Generation)

문서를 자세히 보면, 코드 내부에서 정의한 쿼리 파라미터나 에러 응답에 대한 내용은 없습니다.

당연한 결과이겠죠?... drf-yasg가 내부 코드까지 분석해줄 수는 없으니까요 :(

이때 swagger_auto_schema 데코레이터와 openapi.Parameter, openapi.Schema 통해 빠진 부분을 문서화할 수 있습니다.

쿼리 파라미터

먼저 TestModel1ViewSethello 쿼리 파라미터에 대한 설명을 추가해보겠습니다.

In views.py :

...
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema

class TestModel1ViewSet(viewsets.ModelViewSet):
    """
    Model1의 CRUD
    ---
    Hello, World
    """
    queryset = TestModel1.objects.all()
    serializer_class = TestModel1Serializer

    # manual parameter
    param_hello_hint = openapi.Parameter(
        'hello',
        openapi.IN_QUERY,
        description='this is a description of hello.',
        type=openapi.TYPE_STRING
    )

    @swagger_auto_schema(manual_parameters=[param_hello_hint])
    def list(self, request, *args, **kwargs):
        ...

먼저 쿼리 파라미터에 대한 정보를 Parameter 클래스로 생성합니다. 파라미터 이름, 어떤 부분에 속하는지(QUERY, BODY, PATH 등), 파라미터 설명, 어떤 타입인지를 생성자에 제공합니다.

파라미터 클래스에 대한 자세한 구현은 문서를 참고하시면 좋습니다.

그 후 데코레이터를 이용해 manual_parameters에 생성한 파라미터 정보를 넘겨줍니다.

 

결과 :

Custom Response

다음은 응답에 대한 문서화입니다. TestModel2ViewSet의 경우 코드에서 특수한(?) 에러 응답을 넘기고 있습니다.

하지만 drf-yasg는 응답 200, 페이지네이션된 응답만을 알고 있을 것입니다.

 

응답 문서화 전 (TestModel2ViewSet의 Response 항목) :

 

500에 대한 응답도 넣어보겠습니다.

In views.py :

class TestModel2ViewSet(viewsets.ModelViewSet):
    """
    Model2의 CRUD
    ---
    Hello, World
    """
    queryset = TestModel2.objects.all()
    serializer_class = TestModel2Serializer

    # manual parameter
    param_world_hint = openapi.Parameter(
        'world',
        openapi.IN_QUERY,
        description='this is a description for world.',
        type=openapi.TYPE_INTEGER
    )

    # custom response schema
    error_field = openapi.Schema(
        'error',
        description='this is a error string.',
        type=openapi.TYPE_STRING
    )
    detail_field = openapi.Schema(
        'detail',
        description='this is a detail string.',
        type=openapi.TYPE_STRING
    )
    code_field = openapi.Schema(
        'code',
        description='this is a code number.',
        type=openapi.TYPE_INTEGER
    )
    error_resp = openapi.Schema(
        'response',
        type=openapi.TYPE_OBJECT,
        properties={
            'error': error_field,
            'detail': detail_field,
            'code': code_field
        }
    )

    @swagger_auto_schema(
        manual_parameters=[param_world_hint],
        responses={
            # can use schema or text
            400: 'this is a test description.',
            500: error_resp
        }
    )
    def list(self, request, *args, **kwargs):
        ...

앞의 파라미터 처리부분보다는 길지만 원리는 간단합니다.

필요한 필드(Schema)를 만들고, 이를 적절하게 빌드하는 방식입니다. Schema 역시 Parameter 클래스와 비슷한 인자를 가지고 있습니다.

Schema에 대한 구현은 문서를 참고하시면 되겠습니다.

데코레이터의 responses에는 dict type을 받으며, key는 status code, value는 응답과 관련된 정보입니다. value에는 일반 텍스트, Schema, Serializer가 들어갈 수 있습니다.

 

결과 :

특수한 경우: 하나의 ViewSet에서 여러 개의 QuerySet/Model을 다룰 때 응답 문서화하기

뷰에 아래와 같은 새 뷰셋을 추가했습니다.

In views.py :

class MyViewSet(viewsets.GenericViewSet):
    @action(detail=False)
    def model1(self, request):
        # do something...
        queryset = self.paginate_queryset(self.get_queryset())
        serializer = self.get_serializer(queryset, many=True)
        return self.get_paginated_response(serializer.data)

    @action(detail=False)
    def model2(self, request):
        # do something...
        queryset = self.paginate_queryset(self.get_queryset())
        serializer = self.get_serializer(queryset, many=True)
        return self.get_paginated_response(serializer.data)

역시 yasg가 Response가 무엇인지 판단할 수 없을 텐데요.

 

 

당장 생각나는 방법으로는 @swagger_auto_schema(responses={200: TestModel1Serializer(many=True)}) 를 사용하여 응답 스키마를 지정하는 방법이 있습니다.

하지만 페이지네이션을 사용하고 있다면 위 방법이 문제가 있다는 점을 알 수 있는데요. 바로 count, next 등 페이지네이션 관련 필드가 모두 제외된 채, 단순 Model의 Array로만 문서화됩니다.

 

이때는 queryset과 serializer을 동적으로 결정해주면 해결할 수 있습니다.

In views.py :

class MyViewSet(viewsets.GenericViewSet):
    queryset = {
        'model1': TestModel1.objects.all(),
        'model2': TestModel2.objects.all()
    }

    serializer_classes = {
        'model1': TestModel1Serializer,
        'model2': TestModel2Serializer
    }

    def get_queryset(self):
        if self.action in self.queryset:
            return self.queryset[self.action]
        return super().get_queryset()

    def get_serializer_class(self):
        if self.action in self.serializer_classes:
            return self.serializer_classes[self.action]
        return super().get_serializer_class()

    @action(detail=False)
    def model1(self, request):
        # do something...
        queryset = self.paginate_queryset(self.get_queryset())
        serializer = self.get_serializer(queryset, many=True)
        return self.get_paginated_response(serializer.data)

    @action(detail=False)
    def model2(self, request):
        # do something...
        queryset = self.paginate_queryset(self.get_queryset())
        serializer = self.get_serializer(queryset, many=True)
        return self.get_paginated_response(serializer.data)

 

페이지네이션이 적용된 스키마를 볼 수 있네요 :)

삽질하고 질문을 올렸다가 자문자답한 내용입니다 ㅠ.


제가 준비한 내용은 여기까지입니다.

적용기를 간단하게 작성했는데 도움이 될지 모르겠네요.

문서화 작업하시는 분들 파이팅입니다!!

'공돌이' 카테고리의 다른 글

C, TCP 기반으로 간단한 HTTP 서버 작성하기  (1) 2020.11.22
Python: Decorator  (0) 2020.10.26
Python: Context Manager  (0) 2020.10.26
Python: Generator  (0) 2020.10.26
Coursera 재정지원 (Financial Aid) 요청하기  (0) 2020.07.26