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,38 @@
"""init
Revision ID: df0effc5d87a
Revises:
Create Date: 2025-08-08 11:20:03.816755+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'df0effc5d87a'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('full_name', sa.String(length=255), nullable=True),
sa.Column('role', sa.String(length=32), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

View File

@@ -5,6 +5,9 @@ psycopg2-binary
alembic
pydantic>=2
pydantic-settings
pydantic[email]
python-dotenv
httpx>=0.27
pytest
PyJWT>=2.8
passlib[bcrypt]>=1.7

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.user import UserCreate, LoginRequest, TokenPair, UserRead
from app.services.user_service import UserService
from app.core.security import create_access_token, create_refresh_token, get_current_user, UserClaims
router = APIRouter(prefix="/v1", tags=["auth"])
@router.post("/register", response_model=UserRead, status_code=201)
def register(payload: UserCreate, db: Session = Depends(get_db)):
svc = UserService(db)
try:
user = svc.create_user(email=payload.email, password=payload.password,
full_name=payload.full_name, role=payload.role)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return user
@router.post("/token", response_model=TokenPair)
def token(payload: LoginRequest, db: Session = Depends(get_db)):
svc = UserService(db)
user = svc.authenticate(email=payload.email, password=payload.password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
access = create_access_token(sub=str(user.id), email=user.email, role=user.role)
refresh = create_refresh_token(sub=str(user.id), email=user.email, role=user.role)
return TokenPair(access_token=access, refresh_token=refresh)
class RefreshRequest(LoginRequest.__class__):
refresh_token: str # type: ignore
@router.post("/refresh", response_model=TokenPair)
def refresh_token(req: dict):
# expects: {"refresh_token": "<token>"}
from app.core.security import decode_token
token = req.get("refresh_token")
if not token:
raise HTTPException(status_code=400, detail="Missing refresh_token")
claims = decode_token(token)
if claims.type != "refresh":
raise HTTPException(status_code=400, detail="Not a refresh token")
access = create_access_token(sub=claims.sub, email=claims.email, role=claims.role)
refresh = create_refresh_token(sub=claims.sub, email=claims.email, role=claims.role)
return TokenPair(access_token=access, refresh_token=refresh)
@router.get("/me", response_model=UserRead)
def me(claims: UserClaims = Depends(get_current_user), db: Session = Depends(get_db)):
svc = UserService(db)
u = svc.get_user(claims.sub)
if not u:
raise HTTPException(status_code=404, detail="User not found")
return u

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.security import require_roles
from app.schemas.user import UserRead, UserUpdate, UserCreate
from app.services.user_service import UserService
router = APIRouter(prefix="/v1/users", tags=["users"])
@router.get("", response_model=list[UserRead])
def list_users(offset: int = 0, limit: int = Query(50, le=200), db: Session = Depends(get_db),
_: dict = Depends(require_roles("ADMIN"))):
return UserService(db).list_users(offset=offset, limit=limit)
@router.post("", response_model=UserRead, status_code=201)
def create_user(payload: UserCreate, db: Session = Depends(get_db),
_: dict = Depends(require_roles("ADMIN"))):
try:
return UserService(db).create_user(email=payload.email, password=payload.password,
full_name=payload.full_name, role=payload.role)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{user_id}", response_model=UserRead)
def get_user(user_id: str, db: Session = Depends(get_db),
_: dict = Depends(require_roles("ADMIN"))):
u = UserService(db).get_user(user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
return u
@router.patch("/{user_id}", response_model=UserRead)
def update_user(user_id: str, payload: UserUpdate, db: Session = Depends(get_db),
_: dict = Depends(require_roles("ADMIN"))):
svc = UserService(db)
u = svc.get_user(user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
return svc.update_user(u, full_name=payload.full_name, role=payload.role,
is_active=payload.is_active, password=payload.password)
@router.delete("/{user_id}", status_code=204)
def delete_user(user_id: str, db: Session = Depends(get_db),
_: dict = Depends(require_roles("ADMIN"))):
svc = UserService(db)
u = svc.get_user(user_id)
if not u:
return
svc.delete_user(u)

View File

@@ -0,0 +1,9 @@
from passlib.context import CryptContext
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(p: str) -> str:
return _pwd.hash(p)
def verify_password(p: str, hashed: str) -> bool:
return _pwd.verify(p, hashed)

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Any, Callable, Optional
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")
ACCESS_TOKEN_EXPIRES_MIN = int(os.getenv("ACCESS_TOKEN_EXPIRES_MIN", "15"))
REFRESH_TOKEN_EXPIRES_MIN = int(os.getenv("REFRESH_TOKEN_EXPIRES_MIN", "43200"))
class TokenType(str, Enum):
access = "access"
refresh = "refresh"
class UserClaims(BaseModel):
sub: str
email: str
role: str
type: str
exp: int
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
def _create_token(token_type: TokenType, *, sub: str, email: str, role: str, expires_minutes: int) -> str:
now = datetime.now(timezone.utc)
exp = now + timedelta(minutes=expires_minutes)
payload: dict[str, Any] = {
"sub": sub,
"email": email,
"role": role,
"type": token_type.value,
"exp": int(exp.timestamp()),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def create_access_token(*, sub: str, email: str, role: str) -> str:
return _create_token(TokenType.access, sub=sub, email=email, role=role, expires_minutes=ACCESS_TOKEN_EXPIRES_MIN)
def create_refresh_token(*, sub: str, email: str, role: str) -> str:
return _create_token(TokenType.refresh, sub=sub, email=email, role=role, expires_minutes=REFRESH_TOKEN_EXPIRES_MIN)
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) -> Callable[[UserClaims], UserClaims]:
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,7 @@
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
from .api.routes.auth import router as auth_router
from .api.routes.users import router as users_router
app = FastAPI(title="AUTH Service")
@@ -7,5 +9,6 @@ app = FastAPI(title="AUTH Service")
def health():
return {"status": "ok", "service": "auth"}
# v1 API
app.include_router(ping_router, prefix="/v1")
app.include_router(auth_router)
app.include_router(users_router)

View File

@@ -0,0 +1 @@
from .user import User, Role # noqa: F401

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
import uuid
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Boolean, DateTime
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 Role(str, Enum):
ADMIN = "ADMIN"
MATCHMAKER = "MATCHMAKER"
CLIENT = "CLIENT"
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str | None] = mapped_column(String(255), default=None)
role: Mapped[str] = mapped_column(String(32), default=Role.CLIENT.value, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from typing import Optional, Sequence
from sqlalchemy.orm import Session
from sqlalchemy import select, update, delete
from app.models.user import User
class UserRepository:
def __init__(self, db: Session):
self.db = db
def get(self, user_id) -> Optional[User]:
return self.db.get(User, user_id)
def get_by_email(self, email: str) -> Optional[User]:
stmt = select(User).where(User.email == email)
return self.db.execute(stmt).scalar_one_or_none()
def list(self, *, offset: int = 0, limit: int = 50) -> Sequence[User]:
stmt = select(User).offset(offset).limit(limit).order_by(User.created_at.desc())
return self.db.execute(stmt).scalars().all()
def create(self, *, email: str, password_hash: str, full_name: str | None, role: str) -> User:
user = User(email=email, password_hash=password_hash, full_name=full_name, role=role)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
return user
def update(self, user: User, **fields) -> User:
for k, v in fields.items():
if v is not None:
setattr(user, k, v)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
return user
def delete(self, user: User) -> None:
self.db.delete(user)
self.db.commit()

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, EmailStr, ConfigDict
class UserBase(BaseModel):
email: EmailStr
full_name: Optional[str] = None
role: str = "CLIENT"
is_active: bool = True
class UserCreate(BaseModel):
email: EmailStr
password: str
full_name: Optional[str] = None
role: str = "CLIENT"
class UserUpdate(BaseModel):
full_name: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
password: Optional[str] = None
class UserRead(BaseModel):
id: str
email: EmailStr
full_name: Optional[str] = None
role: str
is_active: bool
model_config = ConfigDict(from_attributes=True)
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from typing import Optional
from sqlalchemy.orm import Session
from app.repositories.user_repository import UserRepository
from app.core.passwords import hash_password, verify_password
from app.models.user import User
class UserService:
def __init__(self, db: Session):
self.repo = UserRepository(db)
# CRUD
def create_user(self, *, email: str, password: str, full_name: str | None, role: str) -> User:
if self.repo.get_by_email(email):
raise ValueError("Email already in use")
pwd_hash = hash_password(password)
return self.repo.create(email=email, password_hash=pwd_hash, full_name=full_name, role=role)
def get_user(self, user_id) -> Optional[User]:
return self.repo.get(user_id)
def get_by_email(self, email: str) -> Optional[User]:
return self.repo.get_by_email(email)
def list_users(self, *, offset: int = 0, limit: int = 50):
return self.repo.list(offset=offset, limit=limit)
def update_user(self, user: User, *, full_name: str | None = None, role: str | None = None,
is_active: bool | None = None, password: str | None = None) -> User:
fields = {}
if full_name is not None: fields["full_name"] = full_name
if role is not None: fields["role"] = role
if is_active is not None: fields["is_active"] = is_active
if password: fields["password_hash"] = hash_password(password)
return self.repo.update(user, **fields)
def delete_user(self, user: User) -> None:
self.repo.delete(user)
# Auth
def authenticate(self, *, email: str, password: str) -> Optional[User]:
user = self.repo.get_by_email(email)
if not user or not user.is_active:
return None
if not verify_password(password, user.password_hash):
return None
return user