api development
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2025-08-08 21:58:36 +09:00
parent d58302c2c8
commit cc87dcc0fa
157 changed files with 14629 additions and 7 deletions

View File

@@ -12,6 +12,7 @@ if SRC_DIR not in sys.path:
sys.path.append(SRC_DIR)
from app.db.session import Base # noqa
from app import models # noqa: F401
config = context.config

View File

@@ -0,0 +1,22 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,56 @@
"""init
Revision ID: 8cc8115aaf0e
Revises:
Create Date: 2025-08-08 11:20:07.718286+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8cc8115aaf0e'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('chat_messages',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('room_id', sa.UUID(), nullable=False),
sa.Column('sender_id', sa.UUID(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chat_messages_room_id'), 'chat_messages', ['room_id'], unique=False)
op.create_index(op.f('ix_chat_messages_sender_id'), 'chat_messages', ['sender_id'], unique=False)
op.create_table('chat_participants',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('room_id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('is_admin', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chat_participants_room_id'), 'chat_participants', ['room_id'], unique=False)
op.create_index(op.f('ix_chat_participants_user_id'), 'chat_participants', ['user_id'], unique=False)
op.create_table('chat_rooms',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('chat_rooms')
op.drop_index(op.f('ix_chat_participants_user_id'), table_name='chat_participants')
op.drop_index(op.f('ix_chat_participants_room_id'), table_name='chat_participants')
op.drop_table('chat_participants')
op.drop_index(op.f('ix_chat_messages_sender_id'), table_name='chat_messages')
op.drop_index(op.f('ix_chat_messages_room_id'), table_name='chat_messages')
op.drop_table('chat_messages')
# ### end Alembic commands ###

View File

@@ -8,3 +8,4 @@ pydantic-settings
python-dotenv
httpx>=0.27
pytest
PyJWT>=2.8

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.security import get_current_user, UserClaims
from app.schemas.chat import RoomCreate, RoomRead, MessageCreate, MessageRead
from app.services.chat_service import ChatService
router = APIRouter(prefix="/v1", tags=["chat"])
@router.post("/rooms", response_model=RoomRead, status_code=201)
def create_room(payload: RoomCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ChatService(db)
room = svc.create_room(title=payload.title, participant_ids=payload.participants, creator_id=user.sub)
return room
@router.get("/rooms", response_model=list[RoomRead])
def my_rooms(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
return ChatService(db).list_rooms_for_user(user.sub)
@router.get("/rooms/{room_id}", response_model=RoomRead)
def get_room(room_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
room = ChatService(db).get_room(room_id)
if not room:
raise HTTPException(status_code=404, detail="Not found")
# NOTE: для простоты опускаем проверку участия (добавьте в проде)
return room
@router.post("/rooms/{room_id}/messages", response_model=MessageRead, status_code=201)
def send_message(room_id: str, payload: MessageCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ChatService(db)
room = svc.get_room(room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
msg = svc.create_message(room_id, user.sub, payload.content)
return msg
@router.get("/rooms/{room_id}/messages", response_model=list[MessageRead])
def list_messages(room_id: str, offset: int = 0, limit: int = Query(100, le=500),
db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ChatService(db)
room = svc.get_room(room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
return svc.list_messages(room_id, offset=offset, limit=limit)

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import os
from enum import Enum
from typing import Any, Callable
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel
JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
class UserClaims(BaseModel):
sub: str
email: str
role: str
type: str
exp: int
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
def decode_token(token: str) -> UserClaims:
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return UserClaims(**payload)
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
except jwt.PyJWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
return decode_token(token)
def require_roles(*roles: str):
def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
if roles and user.role not in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
return user
return dep

View File

@@ -1,5 +1,6 @@
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
from .api.routes.chat import router as chat_router
app = FastAPI(title="CHAT Service")
@@ -7,5 +8,5 @@ app = FastAPI(title="CHAT Service")
def health():
return {"status": "ok", "service": "chat"}
# v1 API
app.include_router(ping_router, prefix="/v1")
app.include_router(chat_router)

View File

@@ -0,0 +1 @@
from .chat import ChatRoom, ChatParticipant, Message # noqa

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Text, ForeignKey, Boolean
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.db.session import Base
class ChatRoom(Base):
__tablename__ = "chat_rooms"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title: Mapped[str | None] = mapped_column(String(255), default=None)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class ChatParticipant(Base):
__tablename__ = "chat_participants"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
class Message(Base):
__tablename__ = "chat_messages"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
sender_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from typing import Sequence, Optional
from sqlalchemy.orm import Session
from sqlalchemy import select, or_
from app.models.chat import ChatRoom, ChatParticipant, Message
class ChatRepository:
def __init__(self, db: Session):
self.db = db
# Rooms
def create_room(self, title: str | None) -> ChatRoom:
r = ChatRoom(title=title)
self.db.add(r)
self.db.commit()
self.db.refresh(r)
return r
def add_participant(self, room_id, user_id, is_admin: bool = False) -> ChatParticipant:
p = ChatParticipant(room_id=room_id, user_id=user_id, is_admin=is_admin)
self.db.add(p)
self.db.commit()
self.db.refresh(p)
return p
def list_rooms_for_user(self, user_id) -> Sequence[ChatRoom]:
stmt = select(ChatRoom).join(ChatParticipant, ChatParticipant.room_id == ChatRoom.id)\
.where(ChatParticipant.user_id == user_id)
return self.db.execute(stmt).scalars().all()
def get_room(self, room_id) -> Optional[ChatRoom]:
return self.db.get(ChatRoom, room_id)
# Messages
def create_message(self, room_id, sender_id, content: str) -> Message:
m = Message(room_id=room_id, sender_id=sender_id, content=content)
self.db.add(m)
self.db.commit()
self.db.refresh(m)
return m
def list_messages(self, room_id, *, offset: int = 0, limit: int = 100) -> Sequence[Message]:
stmt = select(Message).where(Message.room_id == room_id).offset(offset).limit(limit).order_by(Message.created_at.asc())
return self.db.execute(stmt).scalars().all()

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
from typing import Optional
class RoomCreate(BaseModel):
title: Optional[str] = None
participants: list[str] # user IDs
class RoomRead(BaseModel):
id: str
title: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class MessageCreate(BaseModel):
content: str
class MessageRead(BaseModel):
id: str
room_id: str
sender_id: str
content: str
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from typing import Optional, Sequence
from app.repositories.chat_repository import ChatRepository
from app.models.chat import ChatRoom, ChatParticipant, Message
class ChatService:
def __init__(self, db: Session):
self.repo = ChatRepository(db)
def create_room(self, *, title: str | None, participant_ids: list[str], creator_id: str) -> ChatRoom:
room = self.repo.create_room(title)
# creator -> admin
self.repo.add_participant(room.id, creator_id, is_admin=True)
for uid in participant_ids:
if uid != creator_id:
self.repo.add_participant(room.id, uid, is_admin=False)
return room
def list_rooms_for_user(self, user_id: str) -> Sequence[ChatRoom]:
return self.repo.list_rooms_for_user(user_id)
def get_room(self, room_id: str) -> ChatRoom | None:
return self.repo.get_room(room_id)
def create_message(self, room_id: str, sender_id: str, content: str) -> Message:
return self.repo.create_message(room_id, sender_id, content)
def list_messages(self, room_id: str, offset: int = 0, limit: int = 100):
return self.repo.list_messages(room_id, offset=offset, limit=limit)