Skip to content

Commit a70edb4

Browse files
swagger support (#98)
* feat: Integrate drf-spectacular for OpenAPI documentation and add schema endpoints * fix: Add TEMPLATES and STATIC_URL configuration for drf-spectacular * fix: Add trailing slashes to API docs paths and enable DEBUG mode * fix: Replace Pydantic models with inline schemas in Swagger decorators * fix: Update OpenAPI responses to use OpenApiResponse for consistency --------- Co-authored-by: Amit Prakash <[email protected]>
1 parent 666ab05 commit a70edb4

File tree

6 files changed

+297
-5
lines changed

6 files changed

+297
-5
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ PyJWT==2.10.1
2626
requests==2.32.3
2727
email-validator==2.2.0
2828
testcontainers[mongodb]==4.10.0
29+
drf-spectacular==0.28.0

todo/views/auth.py

Lines changed: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from rest_framework import status
55
from django.http import HttpResponseRedirect, HttpResponse
66
from django.conf import settings
7+
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiExample, OpenApiResponse
8+
from drf_spectacular.types import OpenApiTypes
79
from todo.services.google_oauth_service import GoogleOAuthService
810
from todo.services.user_service import UserService
911
from todo.utils.google_jwt_utils import (
@@ -24,6 +26,32 @@
2426

2527

2628
class GoogleLoginView(APIView):
29+
@extend_schema(
30+
operation_id="google_login",
31+
summary="Initiate Google OAuth login",
32+
description="Redirects to Google OAuth authorization URL or returns JSON response with auth URL",
33+
tags=["auth"],
34+
parameters=[
35+
OpenApiParameter(
36+
name="redirectURL",
37+
type=OpenApiTypes.STR,
38+
location=OpenApiParameter.QUERY,
39+
description="URL to redirect after successful authentication",
40+
required=False,
41+
),
42+
OpenApiParameter(
43+
name="format",
44+
type=OpenApiTypes.STR,
45+
location=OpenApiParameter.QUERY,
46+
description="Response format: 'json' for JSON response, otherwise redirects",
47+
required=False,
48+
),
49+
],
50+
responses={
51+
200: OpenApiResponse(description="Google OAuth URL generated successfully"),
52+
302: OpenApiResponse(description="Redirect to Google OAuth URL"),
53+
},
54+
)
2755
def get(self, request: Request):
2856
redirect_url = request.query_params.get("redirectURL")
2957
auth_url, state = GoogleOAuthService.get_authorization_url(redirect_url)
@@ -51,6 +79,40 @@ class GoogleCallbackView(APIView):
5179
The frontend implementation will redirect to the frontend and process the callback via POST request.
5280
"""
5381

82+
@extend_schema(
83+
operation_id="google_callback",
84+
summary="Handle Google OAuth callback",
85+
description="Processes the OAuth callback from Google and creates/updates user account",
86+
tags=["auth"],
87+
parameters=[
88+
OpenApiParameter(
89+
name="code",
90+
type=OpenApiTypes.STR,
91+
location=OpenApiParameter.QUERY,
92+
description="Authorization code from Google",
93+
required=True,
94+
),
95+
OpenApiParameter(
96+
name="state",
97+
type=OpenApiTypes.STR,
98+
location=OpenApiParameter.QUERY,
99+
description="State parameter for CSRF protection",
100+
required=True,
101+
),
102+
OpenApiParameter(
103+
name="error",
104+
type=OpenApiTypes.STR,
105+
location=OpenApiParameter.QUERY,
106+
description="Error from Google OAuth",
107+
required=False,
108+
),
109+
],
110+
responses={
111+
200: OpenApiResponse(description="OAuth callback processed successfully"),
112+
400: OpenApiResponse(description="Bad request - invalid parameters"),
113+
500: OpenApiResponse(description="Internal server error"),
114+
},
115+
)
54116
def get(self, request: Request):
55117
if "error" in request.query_params:
56118
error = request.query_params.get("error")
@@ -274,6 +336,17 @@ def _set_auth_cookies(self, response, tokens):
274336

275337

276338
class GoogleAuthStatusView(APIView):
339+
@extend_schema(
340+
operation_id="google_auth_status",
341+
summary="Check authentication status",
342+
description="Check if the user is authenticated and return user information",
343+
tags=["auth"],
344+
responses={
345+
200: OpenApiResponse(description="Authentication status retrieved successfully"),
346+
401: OpenApiResponse(description="Unauthorized - invalid or missing token"),
347+
500: OpenApiResponse(description="Internal server error"),
348+
},
349+
)
277350
def get(self, request: Request):
278351
access_token = request.COOKIES.get("ext-access")
279352

@@ -304,6 +377,17 @@ def get(self, request: Request):
304377

305378

306379
class GoogleRefreshView(APIView):
380+
@extend_schema(
381+
operation_id="google_refresh_token",
382+
summary="Refresh access token",
383+
description="Refresh the access token using the refresh token from cookies",
384+
tags=["auth"],
385+
responses={
386+
200: OpenApiResponse(description="Token refreshed successfully"),
387+
401: OpenApiResponse(description="Unauthorized - invalid or missing refresh token"),
388+
500: OpenApiResponse(description="Internal server error"),
389+
},
390+
)
307391
def get(self, request: Request):
308392
refresh_token = request.COOKIES.get("ext-refresh")
309393

@@ -344,9 +428,44 @@ def _get_cookie_config(self):
344428

345429

346430
class GoogleLogoutView(APIView):
431+
@extend_schema(
432+
operation_id="google_logout",
433+
summary="Logout user",
434+
description="Logout the user by clearing authentication cookies",
435+
tags=["auth"],
436+
parameters=[
437+
OpenApiParameter(
438+
name="redirectURL",
439+
type=OpenApiTypes.STR,
440+
location=OpenApiParameter.QUERY,
441+
description="URL to redirect after logout",
442+
required=False,
443+
),
444+
OpenApiParameter(
445+
name="format",
446+
type=OpenApiTypes.STR,
447+
location=OpenApiParameter.QUERY,
448+
description="Response format: 'json' for JSON response, otherwise redirects",
449+
required=False,
450+
),
451+
],
452+
responses={
453+
200: OpenApiResponse(description="Logout successful"),
454+
302: OpenApiResponse(description="Redirect to specified URL or home page"),
455+
},
456+
)
347457
def get(self, request: Request):
348458
return self._handle_logout(request)
349459

460+
@extend_schema(
461+
operation_id="google_logout_post",
462+
summary="Logout user (POST)",
463+
description="Logout the user by clearing authentication cookies (POST method)",
464+
tags=["auth"],
465+
responses={
466+
200: OpenApiResponse(description="Logout successful"),
467+
},
468+
)
350469
def post(self, request: Request):
351470
return self._handle_logout(request)
352471

@@ -371,10 +490,9 @@ def _handle_logout(self, request: Request):
371490
redirect_url = redirect_url or "/"
372491
response = HttpResponseRedirect(redirect_url)
373492

374-
response.delete_cookie("ext-access", path="/")
375-
response.delete_cookie("ext-refresh", path="/")
376-
response.delete_cookie(settings.SESSION_COOKIE_NAME, path="/")
377-
request.session.flush()
493+
config = self._get_cookie_config()
494+
response.delete_cookie("ext-access", **config)
495+
response.delete_cookie("ext-refresh", **config)
378496

379497
return response
380498

todo/views/health.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
from rest_framework.views import APIView
22
from rest_framework.response import Response
3+
from drf_spectacular.utils import extend_schema, OpenApiResponse
34
from todo.constants.health import AppHealthStatus, ComponentHealthStatus
45
from todo_project.db.config import DatabaseManager
56

67
database_manager = DatabaseManager()
78

89

910
class HealthView(APIView):
11+
@extend_schema(
12+
operation_id="health_check",
13+
summary="Health check",
14+
description="Check the health status of the application and its components",
15+
tags=["health"],
16+
responses={
17+
200: OpenApiResponse(description="Application is healthy"),
18+
503: OpenApiResponse(description="Application is unhealthy"),
19+
},
20+
)
1021
def get(self, request):
1122
global database_manager
1223
is_db_healthy = database_manager.check_database_health()

todo/views/task.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from rest_framework.request import Request
66
from rest_framework.exceptions import ValidationError
77
from django.conf import settings
8+
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiExample, OpenApiResponse
9+
from drf_spectacular.types import OpenApiTypes
810
from todo.middlewares.jwt_auth import get_current_user_info
911
from todo.serializers.get_tasks_serializer import GetTaskQueryParamsSerializer
1012
from todo.serializers.create_task_serializer import CreateTaskSerializer
@@ -20,6 +22,31 @@
2022

2123

2224
class TaskListView(APIView):
25+
@extend_schema(
26+
operation_id="get_tasks",
27+
summary="Get paginated list of tasks",
28+
description="Retrieve a paginated list of tasks with optional filtering and sorting",
29+
tags=["tasks"],
30+
parameters=[
31+
OpenApiParameter(
32+
name="page",
33+
type=OpenApiTypes.INT,
34+
location=OpenApiParameter.QUERY,
35+
description="Page number for pagination",
36+
),
37+
OpenApiParameter(
38+
name="limit",
39+
type=OpenApiTypes.INT,
40+
location=OpenApiParameter.QUERY,
41+
description="Number of tasks per page",
42+
),
43+
],
44+
responses={
45+
200: OpenApiResponse(description="Successful response"),
46+
400: OpenApiResponse(description="Bad request"),
47+
500: OpenApiResponse(description="Internal server error"),
48+
},
49+
)
2350
def get(self, request: Request):
2451
"""
2552
Retrieve a paginated list of tasks.
@@ -30,6 +57,18 @@ def get(self, request: Request):
3057
response = TaskService.get_tasks(page=query.validated_data["page"], limit=query.validated_data["limit"])
3158
return Response(data=response.model_dump(mode="json", exclude_none=True), status=status.HTTP_200_OK)
3259

60+
@extend_schema(
61+
operation_id="create_task",
62+
summary="Create a new task",
63+
description="Create a new task with the provided details",
64+
tags=["tasks"],
65+
request=CreateTaskSerializer,
66+
responses={
67+
201: OpenApiResponse(description="Task created successfully"),
68+
400: OpenApiResponse(description="Bad request"),
69+
500: OpenApiResponse(description="Internal server error"),
70+
},
71+
)
3372
def post(self, request: Request):
3473
"""
3574
Create a new task.
@@ -92,6 +131,25 @@ def _handle_validation_errors(self, errors):
92131

93132

94133
class TaskDetailView(APIView):
134+
@extend_schema(
135+
operation_id="get_task_by_id",
136+
summary="Get task by ID",
137+
description="Retrieve a single task by its unique identifier",
138+
tags=["tasks"],
139+
parameters=[
140+
OpenApiParameter(
141+
name="task_id",
142+
type=OpenApiTypes.STR,
143+
location=OpenApiParameter.PATH,
144+
description="Unique identifier of the task",
145+
),
146+
],
147+
responses={
148+
200: OpenApiResponse(description="Task retrieved successfully"),
149+
404: OpenApiResponse(description="Task not found"),
150+
500: OpenApiResponse(description="Internal server error"),
151+
},
152+
)
95153
def get(self, request: Request, task_id: str):
96154
"""
97155
Retrieve a single task by ID.
@@ -100,12 +158,58 @@ def get(self, request: Request, task_id: str):
100158
response_data = GetTaskByIdResponse(data=task_dto)
101159
return Response(data=response_data.model_dump(mode="json"), status=status.HTTP_200_OK)
102160

161+
@extend_schema(
162+
operation_id="delete_task",
163+
summary="Delete task",
164+
description="Delete a task by its unique identifier",
165+
tags=["tasks"],
166+
parameters=[
167+
OpenApiParameter(
168+
name="task_id",
169+
type=OpenApiTypes.STR,
170+
location=OpenApiParameter.PATH,
171+
description="Unique identifier of the task to delete",
172+
),
173+
],
174+
responses={
175+
204: OpenApiResponse(description="Task deleted successfully"),
176+
404: OpenApiResponse(description="Task not found"),
177+
500: OpenApiResponse(description="Internal server error"),
178+
},
179+
)
103180
def delete(self, request: Request, task_id: str):
104181
user = get_current_user_info(request)
105182
task_id = ObjectId(task_id)
106183
TaskService.delete_task(task_id, user["user_id"])
107184
return Response(status=status.HTTP_204_NO_CONTENT)
108185

186+
@extend_schema(
187+
operation_id="update_task",
188+
summary="Update or defer task",
189+
description="Partially update a task or defer it based on the action parameter",
190+
tags=["tasks"],
191+
parameters=[
192+
OpenApiParameter(
193+
name="task_id",
194+
type=OpenApiTypes.STR,
195+
location=OpenApiParameter.PATH,
196+
description="Unique identifier of the task",
197+
),
198+
OpenApiParameter(
199+
name="action",
200+
type=OpenApiTypes.STR,
201+
location=OpenApiParameter.QUERY,
202+
description="Action to perform: 'update' or 'defer'",
203+
),
204+
],
205+
request=UpdateTaskSerializer,
206+
responses={
207+
200: OpenApiResponse(description="Task updated successfully"),
208+
400: OpenApiResponse(description="Bad request"),
209+
404: OpenApiResponse(description="Task not found"),
210+
500: OpenApiResponse(description="Internal server error"),
211+
},
212+
)
109213
def patch(self, request: Request, task_id: str):
110214
"""
111215
Partially updates a task by its ID.

0 commit comments

Comments
 (0)