latest api use
This commit is contained in:
521
src/server/fastapi_app.py
Normal file
521
src/server/fastapi_app.py
Normal file
@@ -0,0 +1,521 @@
|
||||
"""
|
||||
FastAPI-based AniWorld Server Application.
|
||||
|
||||
This module implements a comprehensive FastAPI application following the instructions:
|
||||
- Simple master password authentication using JWT
|
||||
- Repository pattern with dependency injection
|
||||
- Proper error handling and validation
|
||||
- OpenAPI documentation
|
||||
- Security best practices
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import hashlib
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, Optional, List
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# Add parent directory to path for imports
|
||||
current_dir = os.path.dirname(__file__)
|
||||
parent_dir = os.path.join(current_dir, '..')
|
||||
sys.path.insert(0, os.path.abspath(parent_dir))
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Depends, Security, status, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings
|
||||
import uvicorn
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('logs/aniworld.log'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Security
|
||||
security = HTTPBearer()
|
||||
|
||||
# Configuration
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings from environment variables."""
|
||||
jwt_secret_key: str = Field(default="your-secret-key-here", env="JWT_SECRET_KEY")
|
||||
password_salt: str = Field(default="default-salt", env="PASSWORD_SALT")
|
||||
master_password_hash: Optional[str] = Field(default=None, env="MASTER_PASSWORD_HASH")
|
||||
master_password: Optional[str] = Field(default=None, env="MASTER_PASSWORD") # For development
|
||||
token_expiry_hours: int = Field(default=24, env="SESSION_TIMEOUT_HOURS")
|
||||
anime_directory: str = Field(default="", env="ANIME_DIRECTORY")
|
||||
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
||||
|
||||
# Additional settings from .env
|
||||
database_url: str = Field(default="sqlite:///./aniworld.db", env="DATABASE_URL")
|
||||
cors_origins: str = Field(default="*", env="CORS_ORIGINS")
|
||||
api_rate_limit: int = Field(default=100, env="API_RATE_LIMIT")
|
||||
default_provider: str = Field(default="aniworld.to", env="DEFAULT_PROVIDER")
|
||||
provider_timeout: int = Field(default=30, env="PROVIDER_TIMEOUT")
|
||||
retry_attempts: int = Field(default=3, env="RETRY_ATTEMPTS")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
extra = "ignore" # Ignore extra environment variables
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# Pydantic Models
|
||||
class LoginRequest(BaseModel):
|
||||
"""Login request model."""
|
||||
password: str = Field(..., min_length=1, description="Master password")
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Login response model."""
|
||||
success: bool
|
||||
message: str
|
||||
token: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
class TokenVerifyResponse(BaseModel):
|
||||
"""Token verification response model."""
|
||||
valid: bool
|
||||
message: str
|
||||
user: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response model."""
|
||||
status: str
|
||||
timestamp: datetime
|
||||
version: str = "1.0.0"
|
||||
services: Dict[str, str]
|
||||
|
||||
class AnimeSearchRequest(BaseModel):
|
||||
"""Anime search request model."""
|
||||
query: str = Field(..., min_length=1, max_length=100)
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
class AnimeResponse(BaseModel):
|
||||
"""Anime response model."""
|
||||
id: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
episodes: int = 0
|
||||
status: str = "Unknown"
|
||||
poster_url: Optional[str] = None
|
||||
|
||||
class EpisodeResponse(BaseModel):
|
||||
"""Episode response model."""
|
||||
id: str
|
||||
anime_id: str
|
||||
episode_number: int
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
duration: Optional[int] = None
|
||||
stream_url: Optional[str] = None
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Error response model."""
|
||||
success: bool = False
|
||||
error: str
|
||||
code: Optional[str] = None
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Authentication utilities
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash password with salt using SHA-256."""
|
||||
salted_password = password + settings.password_salt
|
||||
return hashlib.sha256(salted_password.encode()).hexdigest()
|
||||
|
||||
def verify_master_password(password: str) -> bool:
|
||||
"""Verify password against master password hash."""
|
||||
if not settings.master_password_hash:
|
||||
# If no hash is set, check against plain password (development only)
|
||||
if settings.master_password:
|
||||
return password == settings.master_password
|
||||
return False
|
||||
|
||||
password_hash = hash_password(password)
|
||||
return password_hash == settings.master_password_hash
|
||||
|
||||
def generate_jwt_token() -> Dict[str, Any]:
|
||||
"""Generate JWT token for authentication."""
|
||||
expires_at = datetime.utcnow() + timedelta(hours=settings.token_expiry_hours)
|
||||
payload = {
|
||||
'user': 'master',
|
||||
'exp': expires_at,
|
||||
'iat': datetime.utcnow(),
|
||||
'iss': 'aniworld-fastapi-server'
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, settings.jwt_secret_key, algorithm='HS256')
|
||||
return {
|
||||
'token': token,
|
||||
'expires_at': expires_at
|
||||
}
|
||||
|
||||
def verify_jwt_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Verify and decode JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=['HS256'])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("Token has expired")
|
||||
return None
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"Invalid token: {str(e)}")
|
||||
return None
|
||||
|
||||
async def get_current_user(credentials: HTTPAuthorizationCredentials = Security(security)):
|
||||
"""Dependency to get current authenticated user."""
|
||||
token = credentials.credentials
|
||||
payload = verify_jwt_token(token)
|
||||
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
# Global exception handler
|
||||
async def global_exception_handler(request, exc):
|
||||
"""Global exception handler for unhandled errors."""
|
||||
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"success": False,
|
||||
"error": "Internal Server Error",
|
||||
"code": "INTERNAL_ERROR"
|
||||
}
|
||||
)
|
||||
|
||||
# Application lifespan
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manage application lifespan events."""
|
||||
# Startup
|
||||
logger.info("Starting AniWorld FastAPI server...")
|
||||
logger.info(f"Anime directory: {settings.anime_directory}")
|
||||
logger.info(f"Log level: {settings.log_level}")
|
||||
|
||||
# Verify configuration
|
||||
if not settings.master_password_hash and not settings.master_password:
|
||||
logger.warning("No master password configured! Set MASTER_PASSWORD_HASH or MASTER_PASSWORD environment variable.")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down AniWorld FastAPI server...")
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title="AniWorld API",
|
||||
description="FastAPI-based AniWorld server with simple master password authentication",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Configure appropriately for production
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Request logging middleware
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
"""Log all incoming HTTP requests for debugging."""
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
# Log basic request info
|
||||
client_ip = getattr(request.client, 'host', 'unknown') if request.client else 'unknown'
|
||||
logger.info(f"Request: {request.method} {request.url} from {client_ip}")
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
|
||||
# Log response info
|
||||
process_time = (datetime.utcnow() - start_time).total_seconds()
|
||||
logger.info(f"Response: {response.status_code} ({process_time:.3f}s)")
|
||||
|
||||
return response
|
||||
except Exception as exc:
|
||||
logger.error(f"Request failed: {exc}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"success": False,
|
||||
"error": "Internal Server Error",
|
||||
"code": "REQUEST_FAILED"
|
||||
}
|
||||
)
|
||||
|
||||
# Add global exception handler
|
||||
app.add_exception_handler(Exception, global_exception_handler)
|
||||
|
||||
# Authentication endpoints
|
||||
@app.post("/auth/login", response_model=LoginResponse, tags=["Authentication"])
|
||||
async def login(request_data: LoginRequest, request: Request) -> LoginResponse:
|
||||
"""
|
||||
Authenticate with master password and receive JWT token.
|
||||
|
||||
- **password**: The master password for the application
|
||||
"""
|
||||
try:
|
||||
if not verify_master_password(request_data.password):
|
||||
client_ip = getattr(request.client, 'host', 'unknown') if request.client else 'unknown'
|
||||
logger.warning(f"Failed login attempt from IP: {client_ip}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid master password"
|
||||
)
|
||||
|
||||
token_data = generate_jwt_token()
|
||||
logger.info("Successful authentication")
|
||||
|
||||
return LoginResponse(
|
||||
success=True,
|
||||
message="Authentication successful",
|
||||
token=token_data['token'],
|
||||
expires_at=token_data['expires_at']
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Login error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Authentication service error"
|
||||
)
|
||||
|
||||
@app.get("/auth/verify", response_model=TokenVerifyResponse, tags=["Authentication"])
|
||||
async def verify_token(current_user: Dict = Depends(get_current_user)) -> TokenVerifyResponse:
|
||||
"""
|
||||
Verify the validity of the current JWT token.
|
||||
|
||||
Requires: Bearer token in Authorization header
|
||||
"""
|
||||
return TokenVerifyResponse(
|
||||
valid=True,
|
||||
message="Token is valid",
|
||||
user=current_user.get('user'),
|
||||
expires_at=datetime.fromtimestamp(current_user.get('exp', 0))
|
||||
)
|
||||
|
||||
@app.post("/auth/logout", response_model=Dict[str, Any], tags=["Authentication"])
|
||||
async def logout(current_user: Dict = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
"""
|
||||
Logout endpoint (stateless - client should remove token).
|
||||
|
||||
Requires: Bearer token in Authorization header
|
||||
"""
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Logged out successfully. Please remove the token from client storage."
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
@app.get("/health", response_model=HealthResponse, tags=["System"])
|
||||
async def health_check() -> HealthResponse:
|
||||
"""
|
||||
Application health check endpoint.
|
||||
"""
|
||||
return HealthResponse(
|
||||
status="healthy",
|
||||
timestamp=datetime.utcnow(),
|
||||
services={
|
||||
"authentication": "online",
|
||||
"anime_service": "online",
|
||||
"episode_service": "online"
|
||||
}
|
||||
)
|
||||
|
||||
# Anime endpoints (protected)
|
||||
@app.get("/api/anime/search", response_model=List[AnimeResponse], tags=["Anime"])
|
||||
async def search_anime(
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
) -> List[AnimeResponse]:
|
||||
"""
|
||||
Search for anime by title.
|
||||
|
||||
Requires: Bearer token in Authorization header
|
||||
- **query**: Search query string
|
||||
- **limit**: Maximum number of results (1-100)
|
||||
- **offset**: Number of results to skip for pagination
|
||||
"""
|
||||
# TODO: Implement actual anime search logic
|
||||
# This is a placeholder implementation
|
||||
logger.info(f"Searching anime with query: {query}")
|
||||
|
||||
# Mock data for now
|
||||
mock_results = [
|
||||
AnimeResponse(
|
||||
id=f"anime_{i}",
|
||||
title=f"Sample Anime {i}",
|
||||
description=f"Description for anime {i}",
|
||||
episodes=24,
|
||||
status="Completed"
|
||||
)
|
||||
for i in range(offset + 1, min(offset + limit + 1, 100))
|
||||
if query.lower() in f"sample anime {i}".lower()
|
||||
]
|
||||
|
||||
return mock_results
|
||||
|
||||
@app.get("/api/anime/{anime_id}", response_model=AnimeResponse, tags=["Anime"])
|
||||
async def get_anime(
|
||||
anime_id: str,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
) -> AnimeResponse:
|
||||
"""
|
||||
Get detailed information about a specific anime.
|
||||
|
||||
Requires: Bearer token in Authorization header
|
||||
- **anime_id**: Unique identifier for the anime
|
||||
"""
|
||||
# TODO: Implement actual anime retrieval logic
|
||||
logger.info(f"Fetching anime details for ID: {anime_id}")
|
||||
|
||||
# Mock data for now
|
||||
return AnimeResponse(
|
||||
id=anime_id,
|
||||
title=f"Anime {anime_id}",
|
||||
description=f"Detailed description for anime {anime_id}",
|
||||
episodes=24,
|
||||
status="Completed"
|
||||
)
|
||||
|
||||
@app.get("/api/anime/{anime_id}/episodes", response_model=List[EpisodeResponse], tags=["Episodes"])
|
||||
async def get_anime_episodes(
|
||||
anime_id: str,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
) -> List[EpisodeResponse]:
|
||||
"""
|
||||
Get all episodes for a specific anime.
|
||||
|
||||
Requires: Bearer token in Authorization header
|
||||
- **anime_id**: Unique identifier for the anime
|
||||
"""
|
||||
# TODO: Implement actual episode retrieval logic
|
||||
logger.info(f"Fetching episodes for anime ID: {anime_id}")
|
||||
|
||||
# Mock data for now
|
||||
return [
|
||||
EpisodeResponse(
|
||||
id=f"{anime_id}_ep_{i}",
|
||||
anime_id=anime_id,
|
||||
episode_number=i,
|
||||
title=f"Episode {i}",
|
||||
description=f"Description for episode {i}",
|
||||
duration=1440 # 24 minutes in seconds
|
||||
)
|
||||
for i in range(1, 25) # 24 episodes
|
||||
]
|
||||
|
||||
@app.get("/api/episodes/{episode_id}", response_model=EpisodeResponse, tags=["Episodes"])
|
||||
async def get_episode(
|
||||
episode_id: str,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
) -> EpisodeResponse:
|
||||
"""
|
||||
Get detailed information about a specific episode.
|
||||
|
||||
Requires: Bearer token in Authorization header
|
||||
- **episode_id**: Unique identifier for the episode
|
||||
"""
|
||||
# TODO: Implement actual episode retrieval logic
|
||||
logger.info(f"Fetching episode details for ID: {episode_id}")
|
||||
|
||||
# Mock data for now
|
||||
return EpisodeResponse(
|
||||
id=episode_id,
|
||||
anime_id="sample_anime",
|
||||
episode_number=1,
|
||||
title=f"Episode {episode_id}",
|
||||
description=f"Detailed description for episode {episode_id}",
|
||||
duration=1440
|
||||
)
|
||||
|
||||
# Database health check endpoint
|
||||
@app.get("/api/system/database/health", response_model=Dict[str, Any], tags=["System"])
|
||||
async def database_health(current_user: Dict = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
"""
|
||||
Check database connectivity and health.
|
||||
|
||||
Requires: Bearer token in Authorization header
|
||||
"""
|
||||
# TODO: Implement actual database health check
|
||||
return {
|
||||
"status": "healthy",
|
||||
"connection_pool": "active",
|
||||
"response_time_ms": 15,
|
||||
"last_check": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
# Configuration endpoint
|
||||
@app.get("/api/system/config", response_model=Dict[str, Any], tags=["System"])
|
||||
async def get_system_config(current_user: Dict = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
"""
|
||||
Get system configuration information.
|
||||
|
||||
Requires: Bearer token in Authorization header
|
||||
"""
|
||||
return {
|
||||
"anime_directory": settings.anime_directory,
|
||||
"log_level": settings.log_level,
|
||||
"token_expiry_hours": settings.token_expiry_hours,
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/", tags=["System"])
|
||||
async def root():
|
||||
"""
|
||||
Root endpoint with basic API information.
|
||||
"""
|
||||
return {
|
||||
"message": "AniWorld FastAPI Server",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"health": "/health"
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Configure enhanced logging
|
||||
log_level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||
logging.getLogger().setLevel(log_level)
|
||||
|
||||
logger.info("Starting AniWorld FastAPI server with uvicorn...")
|
||||
logger.info(f"Anime directory: {settings.anime_directory}")
|
||||
logger.info(f"Log level: {settings.log_level}")
|
||||
logger.info("Server will be available at http://127.0.0.1:8000")
|
||||
logger.info("API documentation at http://127.0.0.1:8000/docs")
|
||||
|
||||
# Run the application
|
||||
uvicorn.run(
|
||||
"fastapi_app:app",
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
reload=False, # Disable reload to prevent constant restarting
|
||||
log_level=settings.log_level.lower()
|
||||
)
|
||||
Reference in New Issue
Block a user