refactor(demo): split UI service package

This commit is contained in:
2026-07-14 10:22:49 +03:30
parent d43eb54e2d
commit 59f22320ae
13 changed files with 32 additions and 31 deletions

197
src/gapido_demo/app.py Normal file
View File

@@ -0,0 +1,197 @@
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from dataclasses import asdict
from pathlib import Path
from typing import Annotated, Protocol, TypeVar, cast
import grpc
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from redis.asyncio import Redis
from gapido_auth.config import Settings, get_settings
from gapido_auth.infrastructure.debug_sms_client import debug_sms_key
from gapido_demo.grpc_client import AuthGrpcClient, DemoTokenResponse
STATIC_DIR = Path(__file__).resolve().parent / "static"
T = TypeVar("T")
class AuthClient(Protocol):
async def request_otp(self, mobile: str, purpose: str) -> dict[str, bool]: ...
async def verify_otp(self, mobile: str, code: str, purpose: str) -> DemoTokenResponse: ...
async def refresh_token(self, refresh_token: str) -> DemoTokenResponse: ...
async def revoke_refresh_token(
self, access_token: str, refresh_token: str
) -> dict[str, bool]: ...
async def public_ping(self) -> dict[str, str]: ...
async def user_only(self, access_token: str) -> dict[str, str]: ...
async def admin_only(self, access_token: str) -> dict[str, str]: ...
class RequestOtpBody(BaseModel):
mobile: str = Field(min_length=10, max_length=16)
purpose: str = "login"
class VerifyOtpBody(RequestOtpBody):
code: str = Field(min_length=6, max_length=6)
class RefreshBody(BaseModel):
refresh_token: str = Field(min_length=20)
class TokenBody(BaseModel):
access_token: str = Field(min_length=20)
class RevokeBody(TokenBody):
refresh_token: str = Field(min_length=20)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
app.state.settings = settings
app.state.auth_client = AuthGrpcClient(settings.auth_grpc_target)
app.state.debug_redis = (
Redis.from_url(settings.redis_url, decode_responses=True)
if settings.demo_enable_debug_otp
else None
)
try:
yield
finally:
await app.state.auth_client.close()
if app.state.debug_redis is not None:
await app.state.debug_redis.aclose()
app = FastAPI(title="Gapido Auth Demo", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
def get_auth_client(request: Request) -> AuthClient:
return cast(AuthClient, request.app.state.auth_client)
def get_app_settings(request: Request) -> Settings:
return cast(Settings, request.app.state.settings)
def get_debug_redis(request: Request) -> Redis | None:
return cast(Redis | None, request.app.state.debug_redis)
@app.get("/")
async def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.post("/api/auth/request-otp")
async def request_otp(
body: RequestOtpBody,
client: Annotated[AuthClient, Depends(get_auth_client)],
) -> dict[str, object]:
return cast(
dict[str, object],
await _call_grpc(lambda: client.request_otp(body.mobile, body.purpose)),
)
@app.post("/api/auth/verify-otp")
async def verify_otp(
body: VerifyOtpBody,
client: Annotated[AuthClient, Depends(get_auth_client)],
) -> dict[str, object]:
response = await _call_grpc(lambda: client.verify_otp(body.mobile, body.code, body.purpose))
return cast(dict[str, object], asdict(response))
@app.post("/api/auth/refresh")
async def refresh_token(
body: RefreshBody,
client: Annotated[AuthClient, Depends(get_auth_client)],
) -> dict[str, object]:
response = await _call_grpc(lambda: client.refresh_token(body.refresh_token))
return cast(dict[str, object], asdict(response))
@app.post("/api/auth/revoke")
async def revoke_refresh_token(
body: RevokeBody,
client: Annotated[AuthClient, Depends(get_auth_client)],
) -> dict[str, object]:
return cast(
dict[str, object],
await _call_grpc(
lambda: client.revoke_refresh_token(body.access_token, body.refresh_token)
),
)
@app.post("/api/demo/public")
async def public_demo(client: Annotated[AuthClient, Depends(get_auth_client)]) -> dict[str, object]:
return cast(dict[str, object], await _call_grpc(client.public_ping))
@app.post("/api/demo/user")
async def user_demo(
body: TokenBody,
client: Annotated[AuthClient, Depends(get_auth_client)],
) -> dict[str, object]:
return cast(dict[str, object], await _call_grpc(lambda: client.user_only(body.access_token)))
@app.post("/api/demo/admin")
async def admin_demo(
body: TokenBody,
client: Annotated[AuthClient, Depends(get_auth_client)],
) -> dict[str, object]:
return cast(dict[str, object], await _call_grpc(lambda: client.admin_only(body.access_token)))
@app.get("/api/debug/otp")
async def debug_otp(
mobile: Annotated[str, Query(min_length=10, max_length=16)],
settings: Annotated[Settings, Depends(get_app_settings)],
redis: Annotated[Redis | None, Depends(get_debug_redis)],
) -> dict[str, str | None]:
if not settings.demo_enable_debug_otp or redis is None:
raise HTTPException(status_code=404, detail="debug OTP endpoint is disabled")
code = await redis.get(debug_sms_key(mobile))
return {"mobile": mobile, "code": code}
async def _call_grpc(call: Callable[[], Awaitable[T]]) -> T:
try:
return await call()
except grpc.aio.AioRpcError as exc:
status_code = _grpc_to_http_status(exc.code())
detail = exc.details() or exc.code().name
raise HTTPException(status_code=status_code, detail=detail) from exc
def _grpc_to_http_status(code: grpc.StatusCode) -> int:
match code:
case grpc.StatusCode.INVALID_ARGUMENT:
return 400
case grpc.StatusCode.UNAUTHENTICATED:
return 401
case grpc.StatusCode.PERMISSION_DENIED:
return 403
case grpc.StatusCode.RESOURCE_EXHAUSTED:
return 429
case grpc.StatusCode.UNAVAILABLE:
return 503
case _:
return 502