From 7475a188d606a225940150f4c22c6368d72afa21 Mon Sep 17 00:00:00 2001 From: jungmir Date: Sat, 8 Feb 2025 16:41:21 +0900 Subject: [PATCH 1/7] =?UTF-8?q?Feat:=20swagger=20json=20=EB=B0=B0=ED=8F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- makefile | 3 + swagger/api.json | 269 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 swagger/api.json diff --git a/makefile b/makefile index 92ae9b9..e1a38fe 100644 --- a/makefile +++ b/makefile @@ -23,3 +23,6 @@ e2e-test: test: uv run src/manage.py test + +export-swagger: + uv run src/manage.py spectacular --file swagger/api.json diff --git a/swagger/api.json b/swagger/api.json new file mode 100644 index 0000000..c1617a3 --- /dev/null +++ b/swagger/api.json @@ -0,0 +1,269 @@ +openapi: 3.0.3 +info: + title: HelloPy Backend API + version: 0.1.0 +paths: + /api/faqs/: + get: + operationId: faqs_list + description: |- + ## 모든 FAQ 목록 조회 + ### 특징 : is_deleted는 제외 + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + tags: + - faqs + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedFAQList' + description: '' + post: + operationId: faqs_create + description: |- + ## 새로운 FAQ 생성 + - HelloPy 초기 홈페이지를 위한 api 아니며 + 추후 createㅏ 페이지 생성을 고려한 함수 + - 성공시 status : SUCCESS + - 실패시 status : ERROR + tags: + - faqs + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/FAQ' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/FAQ' + multipart/form-data: + schema: + $ref: '#/components/schemas/FAQ' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/FAQ' + description: '' + /api/faqs/{id}/: + get: + operationId: faqs_retrieve + description: '## 특정 FAQ 조회' + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this FAQ. + required: true + tags: + - faqs + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/FAQ' + description: '' + put: + operationId: faqs_update + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this FAQ. + required: true + tags: + - faqs + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/FAQ' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/FAQ' + multipart/form-data: + schema: + $ref: '#/components/schemas/FAQ' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/FAQ' + description: '' + patch: + operationId: faqs_partial_update + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this FAQ. + required: true + tags: + - faqs + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedFAQ' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedFAQ' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedFAQ' + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/FAQ' + description: '' + delete: + operationId: faqs_destroy + description: |- + ## FAQ 삭제 (Soft Delete) + - 실제 삭제가 아닌 `is_deleted=True`로 변경 + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this FAQ. + required: true + tags: + - faqs + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '204': + description: No response body +components: + schemas: + FAQ: + type: object + properties: + id: + type: integer + readOnly: true + question: + type: string + title: 질문 + maxLength: 255 + answer: + type: string + title: 답변 + is_deleted: + type: boolean + title: 삭제 여부 + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - answer + - created_at + - id + - question + - updated_at + PaginatedFAQList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/FAQ' + PatchedFAQ: + type: object + properties: + id: + type: integer + readOnly: true + question: + type: string + title: 질문 + maxLength: 255 + answer: + type: string + title: 답변 + is_deleted: + type: boolean + title: 삭제 여부 + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + securitySchemes: + basicAuth: + type: http + scheme: basic + cookieAuth: + type: apiKey + in: cookie + name: sessionid From baf34e9d96dfd45fd897f7a0fad2253aa7b7d557 Mon Sep 17 00:00:00 2001 From: jungmir Date: Sun, 9 Feb 2025 15:23:11 +0900 Subject: [PATCH 2/7] =?UTF-8?q?Docs:=20FAQ=20swagger=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- makefile | 2 +- src/config/settings.py | 6 +- src/core/__init__.py | 0 src/core/apps.py | 5 + src/core/errors.py | 14 ++ src/core/middlewares/__init__.py | 0 src/core/middlewares/exception_handler.py | 27 +++ src/core/paginations.py | 50 ++++ src/core/responses/__init__.py | 0 src/core/responses/base.py | 30 +++ src/core/tests.py | 1 + src/faq/views.py | 131 ++--------- swagger/api.json | 269 ---------------------- swagger/api.yaml | 93 ++++++++ 14 files changed, 239 insertions(+), 389 deletions(-) create mode 100644 src/core/__init__.py create mode 100644 src/core/apps.py create mode 100644 src/core/errors.py create mode 100644 src/core/middlewares/__init__.py create mode 100644 src/core/middlewares/exception_handler.py create mode 100644 src/core/paginations.py create mode 100644 src/core/responses/__init__.py create mode 100644 src/core/responses/base.py create mode 100644 src/core/tests.py delete mode 100644 swagger/api.json create mode 100644 swagger/api.yaml diff --git a/makefile b/makefile index e1a38fe..e93bf37 100644 --- a/makefile +++ b/makefile @@ -25,4 +25,4 @@ test: uv run src/manage.py test export-swagger: - uv run src/manage.py spectacular --file swagger/api.json + uv run src/manage.py spectacular --file swagger/api.yaml diff --git a/src/config/settings.py b/src/config/settings.py index a9028b8..0457eb7 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -37,6 +37,7 @@ LOCAL_APPS = [ "config", "faq", + "core", ] # 기본 장고 내장 앱 (Built-in Django Applications) @@ -55,10 +56,11 @@ # rest_framework settings REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", - "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "DEFAULT_PAGINATION_CLASS": "core.paginations.PageNumberPagination", + "EXCEPTION_HANDLER": "rest_framework.views.exception_handler", + "PAGE_SIZE_QUERY_PARAM": "page_size", "PAGE_SIZE": 10, "MAX_PAGE_SIZE": 100, - "PAGE_SIZE_QUERY_PARAM": "page_size", } # spectacular settings SPECTACULAR_SETTINGS = { diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/apps.py b/src/core/apps.py new file mode 100644 index 0000000..5ef1d60 --- /dev/null +++ b/src/core/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = "core" diff --git a/src/core/errors.py b/src/core/errors.py new file mode 100644 index 0000000..86d3cf1 --- /dev/null +++ b/src/core/errors.py @@ -0,0 +1,14 @@ +from rest_framework import status +from rest_framework.exceptions import APIException + + +class BaseException(APIException): + status_code = status.HTTP_200_OK + default_detail = "Internal Server Error" + default_code = "ERROR" + + +class NotExistException(BaseException): + status_code = status.HTTP_200_OK + default_detail = "Not Exist." + default_code = "Not Exist" diff --git a/src/core/middlewares/__init__.py b/src/core/middlewares/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/middlewares/exception_handler.py b/src/core/middlewares/exception_handler.py new file mode 100644 index 0000000..63b0060 --- /dev/null +++ b/src/core/middlewares/exception_handler.py @@ -0,0 +1,27 @@ +from django.core.exceptions import PermissionDenied as DjangoPermissionDenied +from django.http import Http404 +from rest_framework.exceptions import APIException, NotFound, PermissionDenied +from rest_framework.views import set_rollback + +from core.responses.base import BaseResponse + + +def custom_exception_handler(exc, context): + if isinstance(exc, Http404): + exc = NotFound(*(exc.args)) + elif isinstance(exc, DjangoPermissionDenied): + exc = PermissionDenied(*(exc.args)) + + if isinstance(exc, APIException): + headers = {} + if getattr(exc, "auth_header", None): + headers["WWW-Authenticate"] = exc.auth_header + if getattr(exc, "wait", None): + headers["Retry-After"] = "%d" % exc.wait + + error = dict(code=exc.default_code, message=exc.detail) + + set_rollback() + return BaseResponse(error=error, headers=headers, status="ERROR") + + return None diff --git a/src/core/paginations.py b/src/core/paginations.py new file mode 100644 index 0000000..9b4241f --- /dev/null +++ b/src/core/paginations.py @@ -0,0 +1,50 @@ +from rest_framework.pagination import PageNumberPagination as DRFPageNumberPagination + +from .responses.base import BaseResponse + + +class PageNumberPagination(DRFPageNumberPagination): + page_size = 10 + page_size_query_param = "page_size" + max_page_size = 100 # page 단위의 요청 최대 size + + def get_paginated_response(self, data): + pagination = { + "count": self.page.paginator.count, + "next": self.get_next_link(), + "previous": self.get_previous_link(), + } + return BaseResponse(data=data, pagination=pagination) + + def get_paginated_response_schema(self, schema: dict) -> dict: + return { + "type": "object", + "required": ["pagination", "data", "status"], + "properties": { + "status": {"type": "string", "example": "SUCCESS"}, + "pagination": { + "type": "object", + "properties": { + "count": {"type": "integer", "example": 123}, + "next": { + "type": "string", + "nullable": True, + "format": "uri", + "example": "http://api.example.org/accounts/?{page_query_param}=4".format( + page_query_param=self.page_query_param + ), + }, + "previous": { + "type": "string", + "nullable": True, + "format": "uri", + "example": "http://api.example.org/accounts/?{page_query_param}=2".format( + page_query_param=self.page_query_param + ), + }, + }, + "example": 123, + }, + "data": schema, + }, + } diff --git a/src/core/responses/__init__.py b/src/core/responses/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/responses/base.py b/src/core/responses/base.py new file mode 100644 index 0000000..4f064fb --- /dev/null +++ b/src/core/responses/base.py @@ -0,0 +1,30 @@ +from typing import Literal + +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK + + +class BaseResponse(Response): + def __init__( + self, + data: dict | list | None = None, + error: dict | None = None, + pagination: dict | None = None, + template_name: str | None = None, + headers: dict | None = None, + exception: bool = False, + content_type: str | None = None, + status: Literal["SUCCESS", "ERROR"] = "SUCCESS", + ): + response_format = { + "status": status, + "error": error, + "data": data, + "pagination": pagination, + } + super().__init__( + response_format, HTTP_200_OK, template_name, headers, exception, content_type + ) + + def __class_getitem__(cls, *args, **kwargs): + return cls diff --git a/src/core/tests.py b/src/core/tests.py new file mode 100644 index 0000000..a39b155 --- /dev/null +++ b/src/core/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/src/faq/views.py b/src/faq/views.py index 88db483..3537e88 100644 --- a/src/faq/views.py +++ b/src/faq/views.py @@ -1,13 +1,14 @@ -from rest_framework import status -from rest_framework.exceptions import NotFound -from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet +from drf_spectacular.utils import extend_schema +from rest_framework.viewsets import GenericViewSet + +from core.errors import NotExistException +from core.responses.base import BaseResponse from .models import FAQ from .serializers import FAQSerializer -class FAQViewSet(ModelViewSet): +class FAQViewSet(GenericViewSet): serializer_class = FAQSerializer def get_queryset(self): @@ -16,127 +17,23 @@ def get_queryset(self): """ return FAQ.objects.filter(is_deleted=False) + @extend_schema(description="모든 FAQ 목록 조회", responses={}) def list(self, request, *args, **kwargs): """ ## 모든 FAQ 목록 조회 ### 특징 : is_deleted는 제외 """ queryset = self.get_queryset() - page = self.paginate_queryset(queryset) # ✅ 페이지네이션 적용 - if page is not None: - serializer = self.get_serializer(page, many=True) - return self.get_paginated_response( - {"status": "SUCCESS", "error": None, "data": serializer.data} - ) - if not queryset.exists(): - return Response( - { - "status": "ERROR", - "error": {"code": "No Data", "message": "조회할 FAQ 데이터가 없습니다."}, - "data": [], - }, - status=status.HTTP_200_OK, - ) - - serializer = self.get_serializer(queryset, many=True) - return Response( - {"status": "SUCCESS", "error": None, "data": serializer.data, "pagination": {}}, - status=status.HTTP_200_OK, - ) + raise NotExistException() + page = self.paginate_queryset(queryset) # ✅ 페이지네이션 적용 + serializer = self.get_serializer(page or queryset, many=True) + return self.get_paginated_response(serializer.data) def retrieve(self, request, *args, **kwargs): """ ## 특정 FAQ 조회 """ - try: - instance = self.get_object() - serializer = self.get_serializer(instance) - return Response( - { - "status": "SUCCESS", - "error": None, - "data": serializer.data, - }, - status=status.HTTP_200_OK, - ) - except NotFound: - return Response( - { - "status": "ERROR", - "error": {"code": "Not Found", "message": "해당 FAQ를 찾을 수 없습니다."}, - "data": None, - }, - status=status.HTTP_200_OK, - ) - - def create(self, request, *args, **kwargs): - """ - ## 새로운 FAQ 생성 - - HelloPy 초기 홈페이지를 위한 api 아니며 - 추후 createㅏ 페이지 생성을 고려한 함수 - - 성공시 status : SUCCESS - - 실패시 status : ERROR - """ - serializer = self.get_serializer(data=request.data) - if serializer.is_valid(): - serializer.save() - return Response( - {"status": "SUCCESS", "error": None, "data": serializer.data}, - status=status.HTTP_200_OK, - ) - return Response( - { - "status": "ERROR", - "error": {"code": "Validation Error", "message": serializer.errors}, - }, - status=status.HTTP_200_OK, - ) - - def update(self, request, *args, **kwargs): - try: - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - return Response( - {"status": "SUCCESS", "error": None, "data": serializer.data}, - status=status.HTTP_200_OK, - ) - return Response( - { - "status": "ERROR", - "error": {"code": "Validation Error", "message": serializer.errors}, - }, - status=status.HTTP_200_OK, - ) - except NotFound: - return Response( - { - "status": "ERROR", - "error": {"code": "Not Found", "message": "해당 FAQ를 찾을 수 없습니다."}, - }, - status=status.HTTP_200_OK, - ) - - def destroy(self, request, *args, **kwargs): - """ - ## FAQ 삭제 (Soft Delete) - - 실제 삭제가 아닌 `is_deleted=True`로 변경 - """ - try: - faq = self.get_object() - faq.is_deleted = True - faq.save() - return Response( - {"status": "SUCCESS", "error": None, "message": "FAQ 삭제 처리 완료"}, - status=status.HTTP_200_OK, - ) - except NotFound: - return Response( - { - "status": "ERROR", - "error": {"code": "Not Found", "message": "해당 FAQ를 찾을 수 없습니다."}, - }, - status=status.HTTP_200_OK, - ) + instance = self.get_object() + serializer = self.get_serializer(instance) + return BaseResponse(serializer.data) diff --git a/swagger/api.json b/swagger/api.json deleted file mode 100644 index c1617a3..0000000 --- a/swagger/api.json +++ /dev/null @@ -1,269 +0,0 @@ -openapi: 3.0.3 -info: - title: HelloPy Backend API - version: 0.1.0 -paths: - /api/faqs/: - get: - operationId: faqs_list - description: |- - ## 모든 FAQ 목록 조회 - ### 특징 : is_deleted는 제외 - parameters: - - name: page - required: false - in: query - description: A page number within the paginated result set. - schema: - type: integer - - name: page_size - required: false - in: query - description: Number of results to return per page. - schema: - type: integer - tags: - - faqs - security: - - cookieAuth: [] - - basicAuth: [] - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/PaginatedFAQList' - description: '' - post: - operationId: faqs_create - description: |- - ## 새로운 FAQ 생성 - - HelloPy 초기 홈페이지를 위한 api 아니며 - 추후 createㅏ 페이지 생성을 고려한 함수 - - 성공시 status : SUCCESS - - 실패시 status : ERROR - tags: - - faqs - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/FAQ' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/FAQ' - multipart/form-data: - schema: - $ref: '#/components/schemas/FAQ' - required: true - security: - - cookieAuth: [] - - basicAuth: [] - - {} - responses: - '201': - content: - application/json: - schema: - $ref: '#/components/schemas/FAQ' - description: '' - /api/faqs/{id}/: - get: - operationId: faqs_retrieve - description: '## 특정 FAQ 조회' - parameters: - - in: path - name: id - schema: - type: integer - description: A unique integer value identifying this FAQ. - required: true - tags: - - faqs - security: - - cookieAuth: [] - - basicAuth: [] - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/FAQ' - description: '' - put: - operationId: faqs_update - parameters: - - in: path - name: id - schema: - type: integer - description: A unique integer value identifying this FAQ. - required: true - tags: - - faqs - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/FAQ' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/FAQ' - multipart/form-data: - schema: - $ref: '#/components/schemas/FAQ' - required: true - security: - - cookieAuth: [] - - basicAuth: [] - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/FAQ' - description: '' - patch: - operationId: faqs_partial_update - parameters: - - in: path - name: id - schema: - type: integer - description: A unique integer value identifying this FAQ. - required: true - tags: - - faqs - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedFAQ' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PatchedFAQ' - multipart/form-data: - schema: - $ref: '#/components/schemas/PatchedFAQ' - security: - - cookieAuth: [] - - basicAuth: [] - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/FAQ' - description: '' - delete: - operationId: faqs_destroy - description: |- - ## FAQ 삭제 (Soft Delete) - - 실제 삭제가 아닌 `is_deleted=True`로 변경 - parameters: - - in: path - name: id - schema: - type: integer - description: A unique integer value identifying this FAQ. - required: true - tags: - - faqs - security: - - cookieAuth: [] - - basicAuth: [] - - {} - responses: - '204': - description: No response body -components: - schemas: - FAQ: - type: object - properties: - id: - type: integer - readOnly: true - question: - type: string - title: 질문 - maxLength: 255 - answer: - type: string - title: 답변 - is_deleted: - type: boolean - title: 삭제 여부 - created_at: - type: string - format: date-time - readOnly: true - updated_at: - type: string - format: date-time - readOnly: true - required: - - answer - - created_at - - id - - question - - updated_at - PaginatedFAQList: - type: object - required: - - count - - results - properties: - count: - type: integer - example: 123 - next: - type: string - nullable: true - format: uri - example: http://api.example.org/accounts/?page=4 - previous: - type: string - nullable: true - format: uri - example: http://api.example.org/accounts/?page=2 - results: - type: array - items: - $ref: '#/components/schemas/FAQ' - PatchedFAQ: - type: object - properties: - id: - type: integer - readOnly: true - question: - type: string - title: 질문 - maxLength: 255 - answer: - type: string - title: 답변 - is_deleted: - type: boolean - title: 삭제 여부 - created_at: - type: string - format: date-time - readOnly: true - updated_at: - type: string - format: date-time - readOnly: true - securitySchemes: - basicAuth: - type: http - scheme: basic - cookieAuth: - type: apiKey - in: cookie - name: sessionid diff --git a/swagger/api.yaml b/swagger/api.yaml new file mode 100644 index 0000000..e653c87 --- /dev/null +++ b/swagger/api.yaml @@ -0,0 +1,93 @@ +openapi: 3.0.3 +info: + title: HelloPy Backend API + version: 0.1.0 +paths: + /api/faqs/: + get: + operationId: faqs_list + description: 모든 FAQ 목록 조회 + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + tags: + - faqs + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: {} + /api/faqs/{id}/: + get: + operationId: faqs_retrieve + description: '## 특정 FAQ 조회' + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this FAQ. + required: true + tags: + - faqs + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/FAQ' + description: '' +components: + schemas: + FAQ: + type: object + properties: + id: + type: integer + readOnly: true + question: + type: string + title: 질문 + maxLength: 255 + answer: + type: string + title: 답변 + is_deleted: + type: boolean + title: 삭제 여부 + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - answer + - created_at + - id + - question + - updated_at + securitySchemes: + basicAuth: + type: http + scheme: basic + cookieAuth: + type: apiKey + in: cookie + name: sessionid From 744f9cfbb31880f6bf9577621988e164f08bd138 Mon Sep 17 00:00:00 2001 From: jungmir Date: Sun, 9 Feb 2025 17:37:21 +0900 Subject: [PATCH 3/7] =?UTF-8?q?Docs:=20response=20schema=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/settings.py | 2 +- src/core/paginations.py | 32 +--------------- src/core/responses/base.py | 6 +++ src/core/responses/serializer.py | 66 ++++++++++++++++++++++++++++++++ src/core/swagger.py | 41 ++++++++++++++++++++ src/faq/views.py | 28 +++++++++++++- 6 files changed, 141 insertions(+), 34 deletions(-) create mode 100644 src/core/responses/serializer.py create mode 100644 src/core/swagger.py diff --git a/src/config/settings.py b/src/config/settings.py index 0457eb7..2a2ac5c 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -55,7 +55,7 @@ # rest_framework settings REST_FRAMEWORK = { - "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_SCHEMA_CLASS": "core.swagger.CustomAutoSchema", "DEFAULT_PAGINATION_CLASS": "core.paginations.PageNumberPagination", "EXCEPTION_HANDLER": "rest_framework.views.exception_handler", "PAGE_SIZE_QUERY_PARAM": "page_size", diff --git a/src/core/paginations.py b/src/core/paginations.py index 9b4241f..1c1c5c6 100644 --- a/src/core/paginations.py +++ b/src/core/paginations.py @@ -17,34 +17,4 @@ def get_paginated_response(self, data): return BaseResponse(data=data, pagination=pagination) def get_paginated_response_schema(self, schema: dict) -> dict: - return { - "type": "object", - "required": ["pagination", "data", "status"], - "properties": { - "status": {"type": "string", "example": "SUCCESS"}, - "pagination": { - "type": "object", - "properties": { - "count": {"type": "integer", "example": 123}, - "next": { - "type": "string", - "nullable": True, - "format": "uri", - "example": "http://api.example.org/accounts/?{page_query_param}=4".format( - page_query_param=self.page_query_param - ), - }, - "previous": { - "type": "string", - "nullable": True, - "format": "uri", - "example": "http://api.example.org/accounts/?{page_query_param}=2".format( - page_query_param=self.page_query_param - ), - }, - }, - "example": 123, - }, - "data": schema, - }, - } + return schema diff --git a/src/core/responses/base.py b/src/core/responses/base.py index 4f064fb..517ceb1 100644 --- a/src/core/responses/base.py +++ b/src/core/responses/base.py @@ -1,8 +1,11 @@ from typing import Literal from rest_framework.response import Response +from rest_framework.serializers import Serializer from rest_framework.status import HTTP_200_OK +from core.responses.serializer import SuccessResponseSerializer + class BaseResponse(Response): def __init__( @@ -28,3 +31,6 @@ def __init__( def __class_getitem__(cls, *args, **kwargs): return cls + + def get_response_schema(self) -> Serializer: + return SuccessResponseSerializer() diff --git a/src/core/responses/serializer.py b/src/core/responses/serializer.py new file mode 100644 index 0000000..fb60212 --- /dev/null +++ b/src/core/responses/serializer.py @@ -0,0 +1,66 @@ +from rest_framework import serializers + + +class ErrorSerializer(serializers.Serializer): + code = serializers.CharField() + message = serializers.CharField() + + def to_representation(self, instance): + return { + "code": instance.code, + "message": instance.message, + } + + +class PageNumberPaginationSerializer(serializers.Serializer): + count = serializers.IntegerField() + next = serializers.CharField() + previous = serializers.CharField() + + def to_representation(self, instance): + return { + "count": instance.count, + "next": instance.next, + "previous": instance.previous, + } + + +class SuccessResponseSerializer(serializers.Serializer): + status = serializers.CharField(default="SUCCESS") + data = serializers.DictField(required=False) + pagination = PageNumberPaginationSerializer(required=False, allow_null=True) + + def to_representation(self, instance): + return { + "status": instance.status, + "data": instance.data, + "error": None, + "pagination": None, + } + + +class ListSuccessResponseSerializer(serializers.Serializer): + status = serializers.CharField(default="SUCCESS") + data = serializers.ListField(required=False, child=serializers.DictField()) + pagination = PageNumberPaginationSerializer(required=False, allow_null=True) + + def to_representation(self, instance): + return { + "status": instance.status, + "data": instance.data, + "error": None, + "pagination": instance.pagination, + } + + +class ErrorResponseSerializer(serializers.Serializer): + status = serializers.CharField(default="ERROR") + error = ErrorSerializer() + + def to_representation(self, instance): + return { + "status": instance.status, + "error": instance.error, + "data": None, + "pagination": None, + } diff --git a/src/core/swagger.py b/src/core/swagger.py new file mode 100644 index 0000000..928bc86 --- /dev/null +++ b/src/core/swagger.py @@ -0,0 +1,41 @@ +import uritemplate +from drf_spectacular.openapi import AutoSchema +from drf_spectacular.plumbing import is_basic_type, is_list_serializer +from drf_spectacular.utils import _SerializerType +from rest_framework.generics import GenericAPIView +from rest_framework.mixins import ListModelMixin + + +class CustomAutoSchema(AutoSchema): + def _is_list_view(self, serializer: _SerializerType | None = None) -> bool: + """ + partially heuristic approach to determine if a view yields an object or a + list of objects. used for operationId naming, array building and pagination. + defaults to False if all introspection fail. + """ + if serializer is None: + serializer = self.get_response_serializers() + + if isinstance(serializer, dict) and serializer: + # extract likely main serializer from @extend_schema override + serializer = {str(code): s for code, s in serializer.items()} + serializer = serializer[min(serializer)] + + if is_list_serializer(serializer): + return True + if is_basic_type(serializer): + return False + # if hasattr(self.view, 'action'): + # return self.view.action == 'list' + # list responses are "usually" only returned by GET + if self.method != "GET": + return False + if isinstance(self.view, ListModelMixin): + return True + # primary key/lookup variable in path is a strong indicator for retrieve + if isinstance(self.view, GenericAPIView): + lookup_url_kwarg = self.view.lookup_url_kwarg or self.view.lookup_field + if lookup_url_kwarg in uritemplate.variables(self.path): + return False + + return False diff --git a/src/faq/views.py b/src/faq/views.py index 3537e88..24a121d 100644 --- a/src/faq/views.py +++ b/src/faq/views.py @@ -1,8 +1,13 @@ -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.viewsets import GenericViewSet from core.errors import NotExistException from core.responses.base import BaseResponse +from core.responses.serializer import ( + ErrorResponseSerializer, + ListSuccessResponseSerializer, + SuccessResponseSerializer, +) from .models import FAQ from .serializers import FAQSerializer @@ -17,7 +22,13 @@ def get_queryset(self): """ return FAQ.objects.filter(is_deleted=False) - @extend_schema(description="모든 FAQ 목록 조회", responses={}) + @extend_schema( + description="모든 FAQ 목록 조회", + responses={ + "200/성공": ListSuccessResponseSerializer, + "200/에러": ErrorResponseSerializer, + }, + ) def list(self, request, *args, **kwargs): """ ## 모든 FAQ 목록 조회 @@ -30,6 +41,19 @@ def list(self, request, *args, **kwargs): serializer = self.get_serializer(page or queryset, many=True) return self.get_paginated_response(serializer.data) + @extend_schema( + description="모든 FAQ 목록 조회", + responses={ + "200/성공": OpenApiResponse( + response=SuccessResponseSerializer, + description="응답 성공", + ), + "200/에러": OpenApiResponse( + response=ErrorResponseSerializer, + description="응답 에러", + ), + }, + ) def retrieve(self, request, *args, **kwargs): """ ## 특정 FAQ 조회 From 3e83b00c00c81daf50de298d1a5a8f2d652ac612 Mon Sep 17 00:00:00 2001 From: jungmir Date: Mon, 10 Feb 2025 19:35:10 +0900 Subject: [PATCH 4/7] =?UTF-8?q?Docs:=20response=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- swagger/api.yaml | 122 ++++++++++++++++++++++++++++++----------------- 1 file changed, 79 insertions(+), 43 deletions(-) diff --git a/swagger/api.yaml b/swagger/api.yaml index e653c87..b3f0e46 100644 --- a/swagger/api.yaml +++ b/swagger/api.yaml @@ -5,32 +5,31 @@ info: paths: /api/faqs/: get: - operationId: faqs_list + operationId: faqs_retrieve description: 모든 FAQ 목록 조회 - parameters: - - name: page - required: false - in: query - description: A page number within the paginated result set. - schema: - type: integer - - name: page_size - required: false - in: query - description: Number of results to return per page. - schema: - type: integer tags: - faqs security: - cookieAuth: [] - basicAuth: [] - {} - responses: {} + responses: + 200/성공: + content: + application/json: + schema: + $ref: '#/components/schemas/ListSuccessResponse' + description: '' + 200/에러: + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: '' /api/faqs/{id}/: get: - operationId: faqs_retrieve - description: '## 특정 FAQ 조회' + operationId: faqs_retrieve_2 + description: 모든 FAQ 목록 조회 parameters: - in: path name: id @@ -45,44 +44,81 @@ paths: - basicAuth: [] - {} responses: - '200': + 200/성공: content: application/json: schema: - $ref: '#/components/schemas/FAQ' - description: '' + $ref: '#/components/schemas/SuccessResponse' + description: 응답 성공 + 200/에러: + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 응답 에러 components: schemas: - FAQ: + Error: type: object properties: - id: - type: integer - readOnly: true - question: + code: + type: string + message: + type: string + required: + - code + - message + ErrorResponse: + type: object + properties: + status: type: string - title: 질문 - maxLength: 255 - answer: + default: ERROR + error: + $ref: '#/components/schemas/Error' + required: + - error + ListSuccessResponse: + type: object + properties: + status: type: string - title: 답변 - is_deleted: - type: boolean - title: 삭제 여부 - created_at: + default: SUCCESS + data: + type: array + items: + type: object + additionalProperties: {} + pagination: + allOf: + - $ref: '#/components/schemas/PageNumberPagination' + nullable: true + PageNumberPagination: + type: object + properties: + count: + type: integer + next: type: string - format: date-time - readOnly: true - updated_at: + previous: type: string - format: date-time - readOnly: true required: - - answer - - created_at - - id - - question - - updated_at + - count + - next + - previous + SuccessResponse: + type: object + properties: + status: + type: string + default: SUCCESS + data: + type: object + additionalProperties: {} + pagination: + allOf: + - $ref: '#/components/schemas/PageNumberPagination' + nullable: true securitySchemes: basicAuth: type: http From ec12766d34ae9d2bd8e1b839e77cd84ea643ac73 Mon Sep 17 00:00:00 2001 From: jungmir Date: Wed, 19 Feb 2025 20:22:28 +0900 Subject: [PATCH 5/7] =?UTF-8?q?Chore:=20setup-dev=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=8B=9C=20pre-commit=EC=9D=84=20install=20=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/makefile b/makefile index e93bf37..8d1a912 100644 --- a/makefile +++ b/makefile @@ -6,6 +6,9 @@ setup: uv run src/manage.py makemigrations uv run pre-commit install +setup-dev: setup + uv run pre-commit install + migration: uv run src/manage.py migrate From 280866bd9d3f732ec01f24bcbc16574dc2bd5a50 Mon Sep 17 00:00:00 2001 From: jungmir Date: Sat, 22 Feb 2025 09:42:50 +0900 Subject: [PATCH 6/7] =?UTF-8?q?Docs:=20swagger=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/settings.py | 2 +- src/core/swagger.py | 44 +++-------------- src/faq/swagger.py | 104 +++++++++++++++++++++++++++++++++++++++++ src/faq/views.py | 43 ++--------------- 4 files changed, 116 insertions(+), 77 deletions(-) create mode 100644 src/faq/swagger.py diff --git a/src/config/settings.py b/src/config/settings.py index 2a2ac5c..0457eb7 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -55,7 +55,7 @@ # rest_framework settings REST_FRAMEWORK = { - "DEFAULT_SCHEMA_CLASS": "core.swagger.CustomAutoSchema", + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_PAGINATION_CLASS": "core.paginations.PageNumberPagination", "EXCEPTION_HANDLER": "rest_framework.views.exception_handler", "PAGE_SIZE_QUERY_PARAM": "page_size", diff --git a/src/core/swagger.py b/src/core/swagger.py index 928bc86..9b93132 100644 --- a/src/core/swagger.py +++ b/src/core/swagger.py @@ -1,41 +1,9 @@ -import uritemplate -from drf_spectacular.openapi import AutoSchema -from drf_spectacular.plumbing import is_basic_type, is_list_serializer -from drf_spectacular.utils import _SerializerType -from rest_framework.generics import GenericAPIView -from rest_framework.mixins import ListModelMixin +from typing import Callable +from drf_spectacular.utils import extend_schema -class CustomAutoSchema(AutoSchema): - def _is_list_view(self, serializer: _SerializerType | None = None) -> bool: - """ - partially heuristic approach to determine if a view yields an object or a - list of objects. used for operationId naming, array building and pagination. - defaults to False if all introspection fail. - """ - if serializer is None: - serializer = self.get_response_serializers() - if isinstance(serializer, dict) and serializer: - # extract likely main serializer from @extend_schema override - serializer = {str(code): s for code, s in serializer.items()} - serializer = serializer[min(serializer)] - - if is_list_serializer(serializer): - return True - if is_basic_type(serializer): - return False - # if hasattr(self.view, 'action'): - # return self.view.action == 'list' - # list responses are "usually" only returned by GET - if self.method != "GET": - return False - if isinstance(self.view, ListModelMixin): - return True - # primary key/lookup variable in path is a strong indicator for retrieve - if isinstance(self.view, GenericAPIView): - lookup_url_kwarg = self.view.lookup_url_kwarg or self.view.lookup_field - if lookup_url_kwarg in uritemplate.variables(self.path): - return False - - return False +class SwaggerSchema: + @classmethod + def generate_schema(cls, *args, **kwargs) -> Callable: + return extend_schema(*args, **kwargs) diff --git a/src/faq/swagger.py b/src/faq/swagger.py new file mode 100644 index 0000000..b96c3b4 --- /dev/null +++ b/src/faq/swagger.py @@ -0,0 +1,104 @@ +from drf_spectacular.utils import OpenApiExample, OpenApiResponse + +from core.responses.serializer import ( + ErrorResponseSerializer, + ListSuccessResponseSerializer, + SuccessResponseSerializer, +) +from core.swagger import SwaggerSchema + + +class FAQAPIDocs(SwaggerSchema): + @classmethod + def retrieve(cls): + responses = { + "성공": OpenApiResponse( + response=SuccessResponseSerializer, + description="단일 응답 성공", + examples=[ + OpenApiExample( + name="FAQ 조회", + value={ + "status": "SUCCESS", + "data": { + "id": 1, + "question": "질문", + "answer": "답변", + "created_at": "2021-01-01", + }, + }, + ), + ], + ), + "에러": OpenApiResponse( + response=ErrorResponseSerializer, + description="응답 에러", + examples=[ + OpenApiExample( + name="데이터 없음", + value={ + "status": "ERROR", + "error": {"code": "NOT_EXIST", "message": "데이터가 없습니다."}, + }, + ), + ], + ), + } + return cls.generate_schema( + operation_id="faq_retrieve", description="FAQ 조회", responses=responses + ) + + @classmethod + def list(cls): + responses = { + "성공": OpenApiResponse( + response=ListSuccessResponseSerializer, + description="다중 응답 성공", + examples=[ + OpenApiExample( + name="FAQ 목록 조회 (페이지네이션 있음)", + value={ + "status": "SUCCESS", + "data": [ + { + "id": 1, + "question": "질문1", + "answer": "답변1", + }, + ], + "pagination": { + "count": 20, + "next": "http://localhost:8000/api/v1/faqs/?page=2", + "previous": "http://localhost:8000/api/v1/faqs/?page=1", + }, + }, + ), + OpenApiExample( + name="FAQ 목록 조회 (데이터 있음)", + value={ + "status": "SUCCESS", + "data": [ + { + "id": 1, + "question": "질문1", + "answer": "답변1", + }, + ], + "pagination": {"count": 1, "next": None, "previous": None}, + }, + ), + OpenApiExample( + name="FAQ 목록 조회 (데이터 없음)", + value={ + "status": "SUCCESS", + "data": [], + "pagination": {"count": 0, "next": None, "previous": None}, + }, + ), + ], + ), + "에러": OpenApiResponse(response=ErrorResponseSerializer, description="응답 에러"), + } + return cls.generate_schema( + operation_id="faq_list", description="모든 FAQ 목록 조회", responses=responses + ) diff --git a/src/faq/views.py b/src/faq/views.py index 24a121d..dc52f05 100644 --- a/src/faq/views.py +++ b/src/faq/views.py @@ -1,39 +1,22 @@ -from drf_spectacular.utils import OpenApiResponse, extend_schema +from drf_spectacular.utils import extend_schema_view from rest_framework.viewsets import GenericViewSet from core.errors import NotExistException from core.responses.base import BaseResponse -from core.responses.serializer import ( - ErrorResponseSerializer, - ListSuccessResponseSerializer, - SuccessResponseSerializer, -) from .models import FAQ from .serializers import FAQSerializer +from .swagger import FAQAPIDocs +@extend_schema_view(list=FAQAPIDocs.list(), retrieve=FAQAPIDocs.retrieve()) class FAQViewSet(GenericViewSet): serializer_class = FAQSerializer def get_queryset(self): - """ - ## queryset에서 is_deleted는 제외시켜주는 역할 - """ return FAQ.objects.filter(is_deleted=False) - @extend_schema( - description="모든 FAQ 목록 조회", - responses={ - "200/성공": ListSuccessResponseSerializer, - "200/에러": ErrorResponseSerializer, - }, - ) - def list(self, request, *args, **kwargs): - """ - ## 모든 FAQ 목록 조회 - ### 특징 : is_deleted는 제외 - """ + def list(self, request, *args, **kwargs) -> BaseResponse: queryset = self.get_queryset() if not queryset.exists(): raise NotExistException() @@ -41,23 +24,7 @@ def list(self, request, *args, **kwargs): serializer = self.get_serializer(page or queryset, many=True) return self.get_paginated_response(serializer.data) - @extend_schema( - description="모든 FAQ 목록 조회", - responses={ - "200/성공": OpenApiResponse( - response=SuccessResponseSerializer, - description="응답 성공", - ), - "200/에러": OpenApiResponse( - response=ErrorResponseSerializer, - description="응답 에러", - ), - }, - ) - def retrieve(self, request, *args, **kwargs): - """ - ## 특정 FAQ 조회 - """ + def retrieve(self, request, *args, **kwargs) -> BaseResponse: instance = self.get_object() serializer = self.get_serializer(instance) return BaseResponse(serializer.data) From 12df2f13c03145ccf4f226b520ef7d521b362713 Mon Sep 17 00:00:00 2001 From: jungmir Date: Tue, 25 Feb 2025 20:26:02 +0900 Subject: [PATCH 7/7] =?UTF-8?q?Chore:=20=EC=A4=91=EB=B3=B5=20command=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/makefile b/makefile index 8d1a912..1dd27b6 100644 --- a/makefile +++ b/makefile @@ -4,7 +4,6 @@ HOST=0.0.0.0 setup: uv sync uv run src/manage.py makemigrations - uv run pre-commit install setup-dev: setup uv run pre-commit install