diff --git a/makefile b/makefile index 92ae9b9..1dd27b6 100644 --- a/makefile +++ b/makefile @@ -4,6 +4,8 @@ HOST=0.0.0.0 setup: uv sync uv run src/manage.py makemigrations + +setup-dev: setup uv run pre-commit install migration: @@ -23,3 +25,6 @@ e2e-test: test: uv run src/manage.py test + +export-swagger: + uv run src/manage.py spectacular --file swagger/api.yaml diff --git a/src/config/settings.py b/src/config/settings.py index a82b854..11732d2 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -35,6 +35,7 @@ LOCAL_APPS = [ "config", "faq", + "core", "merchandise", ] @@ -54,10 +55,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..1c1c5c6 --- /dev/null +++ b/src/core/paginations.py @@ -0,0 +1,20 @@ +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 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..517ceb1 --- /dev/null +++ b/src/core/responses/base.py @@ -0,0 +1,36 @@ +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__( + 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 + + 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..9b93132 --- /dev/null +++ b/src/core/swagger.py @@ -0,0 +1,9 @@ +from typing import Callable + +from drf_spectacular.utils import extend_schema + + +class SwaggerSchema: + @classmethod + def generate_schema(cls, *args, **kwargs) -> Callable: + return extend_schema(*args, **kwargs) 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/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 88db483..dc52f05 100644 --- a/src/faq/views.py +++ b/src/faq/views.py @@ -1,142 +1,30 @@ -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_view +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 +from .swagger import FAQAPIDocs -class FAQViewSet(ModelViewSet): +@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) - def list(self, request, *args, **kwargs): - """ - ## 모든 FAQ 목록 조회 - ### 특징 : is_deleted는 제외 - """ + def list(self, request, *args, **kwargs) -> BaseResponse: 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, - ) - - 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, - ) + raise NotExistException() + page = self.paginate_queryset(queryset) # ✅ 페이지네이션 적용 + serializer = self.get_serializer(page or queryset, many=True) + return self.get_paginated_response(serializer.data) - 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, - ) + def retrieve(self, request, *args, **kwargs) -> BaseResponse: + instance = self.get_object() + serializer = self.get_serializer(instance) + return BaseResponse(serializer.data) diff --git a/swagger/api.yaml b/swagger/api.yaml new file mode 100644 index 0000000..b3f0e46 --- /dev/null +++ b/swagger/api.yaml @@ -0,0 +1,129 @@ +openapi: 3.0.3 +info: + title: HelloPy Backend API + version: 0.1.0 +paths: + /api/faqs/: + get: + operationId: faqs_retrieve + description: 모든 FAQ 목록 조회 + tags: + - faqs + security: + - cookieAuth: [] + - basicAuth: [] + - {} + 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_2 + 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/SuccessResponse' + description: 응답 성공 + 200/에러: + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 응답 에러 +components: + schemas: + Error: + type: object + properties: + code: + type: string + message: + type: string + required: + - code + - message + ErrorResponse: + type: object + properties: + status: + type: string + default: ERROR + error: + $ref: '#/components/schemas/Error' + required: + - error + ListSuccessResponse: + type: object + properties: + status: + type: string + 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 + previous: + type: string + required: + - 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 + scheme: basic + cookieAuth: + type: apiKey + in: cookie + name: sessionid