refactor(demo): split UI service package
This commit is contained in:
197
src/gapido_demo/app.py
Normal file
197
src/gapido_demo/app.py
Normal 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
|
||||
Reference in New Issue
Block a user