feat(): add validation and documentation to input schemas

This commit is contained in:
gitgernit
2025-11-21 13:45:52 +03:00
parent 3eb4666eff
commit 483c7f90ea
7 changed files with 248 additions and 56 deletions
+67 -15
View File
@@ -1,7 +1,7 @@
from dishka import FromDishka
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, SecretStr
from pydantic import BaseModel, EmailStr, Field, SecretStr
from template_project.application.auth_identity.errors import (
AuthError,
@@ -18,15 +18,29 @@ router = APIRouter(route_class=DishkaRoute, tags=["Auth"])
class EmailSignUpRequest(BaseModel):
email: str
password: SecretStr
email: EmailStr = Field(..., description="User email address", examples=["user@example.com"])
password: SecretStr = Field(
..., min_length=8, description="User password (minimum 8 characters)", examples=["password123"]
)
model_config = {"json_schema_extra": {"example": {"email": "user@example.com", "password": "password123"}}}
class EmailSignUpResponse(BaseModel):
access_token: str
access_token: str = Field(..., description="JWT access token for authentication")
model_config = {"json_schema_extra": {"example": {"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}}}
@router.post("/auth/sign_up/email")
@router.post(
"/auth/sign_up/email",
summary="Sign up with email",
description="Register a new user with email and password",
responses={
200: {"description": "User successfully registered", "model": EmailSignUpResponse},
409: {"description": "User with this email already exists"},
},
)
async def sign_up_email(
request: EmailSignUpRequest,
interactor: FromDishka[SignUpInteractor],
@@ -44,14 +58,26 @@ async def sign_up_email(
class YandexSignUpRequest(BaseModel):
code: str
code: str = Field(..., min_length=1, description="Yandex OAuth authorization code", examples=["abc123def456"])
model_config = {"json_schema_extra": {"example": {"code": "abc123def456"}}}
class YandexSignUpResponse(BaseModel):
access_token: str
access_token: str = Field(..., description="JWT access token for authentication")
model_config = {"json_schema_extra": {"example": {"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}}}
@router.post("/auth/sign_up/yandex")
@router.post(
"/auth/sign_up/yandex",
summary="Sign up with Yandex",
description="Register a new user using Yandex OAuth",
responses={
200: {"description": "User successfully registered", "model": YandexSignUpResponse},
401: {"description": "Authentication failed - invalid code or OAuth error"},
},
)
async def sign_up_yandex(
request: YandexSignUpRequest,
interactor: FromDishka[SignUpInteractor],
@@ -66,15 +92,28 @@ async def sign_up_yandex(
class EmailSignInRequest(BaseModel):
email: str
password: SecretStr
email: EmailStr = Field(..., description="User email address", examples=["user@example.com"])
password: SecretStr = Field(..., min_length=1, description="User password", examples=["password123"])
model_config = {"json_schema_extra": {"example": {"email": "user@example.com", "password": "password123"}}}
class EmailSignInResponse(BaseModel):
access_token: str
access_token: str = Field(..., description="JWT access token for authentication")
model_config = {"json_schema_extra": {"example": {"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}}}
@router.post("/auth/sign_in/email")
@router.post(
"/auth/sign_in/email",
summary="Sign in with email",
description="Authenticate user with email and password",
responses={
200: {"description": "User successfully authenticated", "model": EmailSignInResponse},
401: {"description": "Invalid credentials"},
404: {"description": "User not found"},
},
)
async def sign_in_email(
request: EmailSignInRequest,
interactor: FromDishka[SignInInteractor],
@@ -94,14 +133,27 @@ async def sign_in_email(
class YandexSignInRequest(BaseModel):
code: str
code: str = Field(..., min_length=1, description="Yandex OAuth authorization code", examples=["abc123def456"])
model_config = {"json_schema_extra": {"example": {"code": "abc123def456"}}}
class YandexSignInResponse(BaseModel):
access_token: str
access_token: str = Field(..., description="JWT access token for authentication")
model_config = {"json_schema_extra": {"example": {"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}}}
@router.post("/auth/sign_in/yandex")
@router.post(
"/auth/sign_in/yandex",
summary="Sign in with Yandex",
description="Authenticate user using Yandex OAuth",
responses={
200: {"description": "User successfully authenticated", "model": YandexSignInResponse},
401: {"description": "Invalid authorization code or authentication failed"},
404: {"description": "User not found"},
},
)
async def sign_in_yandex(
request: YandexSignInRequest,
interactor: FromDishka[SignInInteractor],
@@ -1,14 +1,23 @@
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter
from pydantic import BaseModel
from pydantic import BaseModel, Field
router = APIRouter(route_class=DishkaRoute, tags=["Health"])
class HealthcheckResponse(BaseModel):
ok: bool
ok: bool = Field(..., description="Service health status")
model_config = {"json_schema_extra": {"example": {"ok": True}}}
@router.get("/healthcheck")
@router.get(
"/healthcheck",
summary="Health check",
description="Check if the service is running and healthy",
responses={
200: {"description": "Service is healthy", "model": HealthcheckResponse},
},
)
async def healthcheck() -> HealthcheckResponse:
return HealthcheckResponse(ok=True)
@@ -2,7 +2,7 @@ from dishka import FromDishka
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer
from pydantic import BaseModel
from pydantic import BaseModel, Field
from template_project.application.notification_device.errors import NotificationDeviceNotFoundError
from template_project.application.notification_device.interactors.register_device import (
@@ -19,15 +19,36 @@ router = APIRouter(route_class=DishkaRoute, tags=["Notifications"], dependencies
class SendNotificationRequestModel(BaseModel):
title: str
body: str
title: str = Field(..., min_length=1, max_length=100, description="Notification title", examples=["New Message"])
body: str = Field(
..., min_length=1, max_length=500, description="Notification body text", examples=["You have a new message"]
)
model_config = {"json_schema_extra": {"example": {"title": "New Message", "body": "You have a new message"}}}
class RegisterNotificationDeviceRequestModel(BaseModel):
device_id: str
device_id: str = Field(
...,
min_length=1,
max_length=200,
description="Device ID for push notifications (FCM token)",
examples=["fcm_token_123456789"],
)
model_config = {"json_schema_extra": {"example": {"device_id": "fcm_token_123456789"}}}
@router.post("/notifications/send")
@router.post(
"/notifications/send",
summary="Send notification",
description="Send a push notification to the current user's registered device",
responses={
200: {"description": "Notification sent successfully"},
401: {"description": "Unauthorized - invalid or missing token"},
404: {"description": "Notification device not found - user needs to register device first"},
},
)
async def send_notification(
request: SendNotificationRequestModel,
interactor: FromDishka[NotificationInteractor],
@@ -39,7 +60,15 @@ async def send_notification(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notification device not found") from error
@router.post("/notifications/register_device")
@router.post(
"/notifications/register_device",
summary="Register notification device",
description="Register a device ID (FCM token) for push notifications for the current user",
responses={
200: {"description": "Device registered successfully"},
401: {"description": "Unauthorized - invalid or missing token"},
},
)
async def register_notification_device(
request: RegisterNotificationDeviceRequestModel,
interactor: FromDishka[RegisterNotificationDeviceInteractor],
+84 -24
View File
@@ -2,7 +2,7 @@ from dishka import FromDishka
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer
from pydantic import BaseModel
from pydantic import BaseModel, EmailStr, Field, HttpUrl
from template_project.application.user.entity import UserId
from template_project.application.user.profile.interactors.get_profile import GetProfileInteractor
@@ -16,16 +16,39 @@ router = APIRouter(route_class=DishkaRoute, tags=["Profile"], dependencies=[Depe
class GetProfileResponse(BaseModel):
id: UserId
email: str | None
display_name: str | None
first_name: str | None
last_name: str | None
avatar_url: str | None
phone: str | None
id: UserId = Field(..., description="User ID")
email: str | None = Field(None, description="User email address")
display_name: str | None = Field(None, description="User display name")
first_name: str | None = Field(None, description="User first name")
last_name: str | None = Field(None, description="User last name")
avatar_url: str | None = Field(None, description="URL to user avatar")
phone: str | None = Field(None, description="User phone number")
model_config = {
"json_schema_extra": {
"example": {
"id": "01234567-89ab-cdef-0123-456789abcdef",
"email": "user@example.com",
"display_name": "John Doe",
"first_name": "John",
"last_name": "Doe",
"avatar_url": "https://example.com/avatar.jpg",
"phone": "+1234567890",
}
}
}
@router.get("/profile")
@router.get(
"/profile",
summary="Get user profile",
description="Retrieve the current authenticated user's profile information",
responses={
200: {"description": "Profile retrieved successfully", "model": GetProfileResponse},
401: {"description": "Unauthorized - invalid or missing token"},
404: {"description": "Profile not found"},
},
)
async def get_profile(
interactor: FromDishka[GetProfileInteractor],
) -> GetProfileResponse:
@@ -44,25 +67,62 @@ async def get_profile(
class PatchProfileRequestModel(BaseModel):
email: str | None = None
display_name: str | None = None
first_name: str | None = None
last_name: str | None = None
avatar_url: str | None = None
phone: str | None = None
email: EmailStr | None = Field(None, description="User email address", examples=["user@example.com"])
display_name: str | None = Field(None, max_length=100, description="User display name", examples=["John Doe"])
first_name: str | None = Field(None, max_length=50, description="User first name", examples=["John"])
last_name: str | None = Field(None, max_length=50, description="User last name", examples=["Doe"])
avatar_url: HttpUrl | None = Field(
None, description="URL to user avatar", examples=["https://example.com/avatar.jpg"]
)
phone: str | None = Field(None, max_length=20, description="User phone number", examples=["+1234567890"])
model_config = {
"json_schema_extra": {
"example": {
"email": "user@example.com",
"display_name": "John Doe",
"first_name": "John",
"last_name": "Doe",
"avatar_url": "https://example.com/avatar.jpg",
"phone": "+1234567890",
}
}
}
class PatchProfileResponse(BaseModel):
id: UserId
email: str | None
display_name: str | None
first_name: str | None
last_name: str | None
avatar_url: str | None
phone: str | None
id: UserId = Field(..., description="User ID")
email: str | None = Field(None, description="User email address")
display_name: str | None = Field(None, description="User display name")
first_name: str | None = Field(None, description="User first name")
last_name: str | None = Field(None, description="User last name")
avatar_url: str | None = Field(None, description="URL to user avatar")
phone: str | None = Field(None, description="User phone number")
model_config = {
"json_schema_extra": {
"example": {
"id": "01234567-89ab-cdef-0123-456789abcdef",
"email": "user@example.com",
"display_name": "John Doe",
"first_name": "John",
"last_name": "Doe",
"avatar_url": "https://example.com/avatar.jpg",
"phone": "+1234567890",
}
}
}
@router.patch("/profile")
@router.patch(
"/profile",
summary="Update user profile",
description="Update the current authenticated user's profile information. All fields are optional.",
responses={
200: {"description": "Profile updated successfully", "model": PatchProfileResponse},
401: {"description": "Unauthorized - invalid or missing token"},
},
)
async def patch_profile(
request: PatchProfileRequestModel,
interactor: FromDishka[PatchProfileInteractor],
@@ -72,7 +132,7 @@ async def patch_profile(
display_name=request.display_name,
first_name=request.first_name,
last_name=request.last_name,
avatar_url=request.avatar_url,
avatar_url=str(request.avatar_url) if request.avatar_url else None,
phone=request.phone,
)
response = await interactor.execute(patch_request)
+20 -8
View File
@@ -2,9 +2,9 @@ from io import BytesIO
from dishka import FromDishka
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter, Depends, UploadFile, status
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, UploadFile
from fastapi.security import HTTPBearer
from pydantic import BaseModel, Field
from uuid_utils.compat import uuid7
from template_project.application.common.file_storage import FileStorage
@@ -13,16 +13,28 @@ security = HTTPBearer()
router = APIRouter(route_class=DishkaRoute, tags=["Storage"], dependencies=[Depends(security)])
@router.post("/storage/upload_file")
class UploadFileResponse(BaseModel):
path: str = Field(..., description="Generated file path (UUID7)")
model_config = {"json_schema_extra": {"example": {"path": "01234567-89ab-cdef-0123-456789abcdef"}}}
@router.post(
"/storage/upload_file",
summary="Upload file",
description="Upload a file to storage. The file path is automatically generated using UUID7.",
responses={
200: {"description": "File uploaded successfully", "model": UploadFileResponse},
401: {"description": "Unauthorized - invalid or missing token"},
413: {"description": "File too large"},
},
)
async def upload_file(
file: UploadFile,
file_storage: FromDishka[FileStorage],
) -> JSONResponse:
) -> UploadFileResponse:
path = str(uuid7())
file_content = await file.read()
file_io = BytesIO(file_content)
await file_storage.upload(path=path, image=file_io)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={"path": path},
)
return UploadFileResponse(path=path)