Refactor app initialization and UI routing

Move lifespan and UI setup to separate modules to improve organization and maintainability.

Co-authored-by: db2002dominic <db2002dominic@gmail.com>
This commit is contained in:
Cursor Agent
2025-12-03 10:31:00 +00:00
co-authored by db2002dominic
parent 42c2ddb355
commit fcfc5d1473
3 changed files with 284 additions and 276 deletions
+7 -276
View File
@@ -1,33 +1,21 @@
import asyncio
import os
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse
from starlette.exceptions import HTTPException
from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_cache_refresher, providers_router
from ..nip91 import announce_provider
from ..payment.models import (
cleanup_enabled_models_periodically,
models_router,
update_sats_pricing,
)
from ..payment.price import update_prices_periodically
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
from ..wallet import periodic_payout
from ..discovery import providers_router
from ..payment.models import models_router
from ..proxy import proxy_router
from .admin import admin_router
from .db import create_session, init_db, run_migrations
from .exceptions import general_exception_handler, http_exception_handler
from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware
from .settings import SettingsService
from .settings import settings as global_settings
from .tasks import lifespan
from .ui import setup_ui_routes
# Initialize logging first
setup_logging()
@@ -39,126 +27,6 @@ else:
__version__ = "0.2.1"
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
logger.info("Application startup initiated", extra={"version": __version__})
btc_price_task = None
pricing_task = None
payout_task = None
nip91_task = None
providers_task = None
models_refresh_task = None
models_cleanup_task = None
model_maps_refresh_task = None
try:
# Run database migrations on startup
# This ensures the database schema is always up-to-date in production
# Migrations are idempotent - running them multiple times is safe
logger.info("Running database migrations")
run_migrations()
# Initialize database connection pools
# This creates any tables that might not be tracked by migrations yet
await init_db()
# Initialize application settings (env -> computed -> DB precedence)
async with create_session() as session:
s = await SettingsService.initialize(session)
# Apply app metadata from settings
try:
app.title = s.name
app.description = s.description
except Exception:
pass
# await ensure_models_bootstrapped()
from ..payment.price import _update_prices
from ..proxy import get_upstreams
from ..upstream.helpers import refresh_upstreams_models_periodically
_update_prices_task = asyncio.create_task(_update_prices())
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
btc_price_task = asyncio.create_task(update_prices_periodically())
pricing_task = asyncio.create_task(update_sats_pricing())
if global_settings.models_refresh_interval_seconds > 0:
models_refresh_task = asyncio.create_task(
refresh_upstreams_models_periodically(get_upstreams())
)
models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically())
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
payout_task = asyncio.create_task(periodic_payout())
if global_settings.nsec:
nip91_task = asyncio.create_task(announce_provider())
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
# ensure both setup tasks complete
await asyncio.gather(
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
)
yield
except Exception as e:
logger.error(
"Application startup failed",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise
finally:
logger.info("Application shutdown initiated")
if btc_price_task is not None:
btc_price_task.cancel()
if pricing_task is not None:
pricing_task.cancel()
if payout_task is not None:
payout_task.cancel()
if nip91_task is not None:
nip91_task.cancel()
if providers_task is not None:
providers_task.cancel()
if models_refresh_task is not None:
models_refresh_task.cancel()
if models_cleanup_task is not None:
models_cleanup_task.cancel()
if model_maps_refresh_task is not None:
model_maps_refresh_task.cancel()
try:
tasks_to_wait = []
if btc_price_task is not None:
tasks_to_wait.append(btc_price_task)
if pricing_task is not None:
tasks_to_wait.append(pricing_task)
if payout_task is not None:
tasks_to_wait.append(payout_task)
if nip91_task is not None:
tasks_to_wait.append(nip91_task)
if providers_task is not None:
tasks_to_wait.append(providers_task)
if models_refresh_task is not None:
tasks_to_wait.append(models_refresh_task)
if models_cleanup_task is not None:
tasks_to_wait.append(models_cleanup_task)
if model_maps_refresh_task is not None:
tasks_to_wait.append(model_maps_refresh_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
logger.info("Background tasks stopped successfully")
except Exception as e:
logger.error(
"Error stopping background tasks",
extra={"error": str(e), "error_type": type(e).__name__},
)
app = FastAPI(version=__version__, lifespan=lifespan)
@@ -197,144 +65,7 @@ async def providers() -> RedirectResponse:
return RedirectResponse("/v1/providers/")
UI_DIST_PATH = Path(__file__).parent.parent.parent / "ui_out"
if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
logger.info(f"Serving static UI from {UI_DIST_PATH}")
app.mount(
"/_next",
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
name="next-static",
)
@app.get("/", include_in_schema=False)
async def serve_root_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
# Add explicit route for /index.txt to redirect to /
@app.get("/index.txt", include_in_schema=False)
async def redirect_index_txt() -> RedirectResponse:
return RedirectResponse("/")
@app.get("/admin")
async def admin_redirect() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
@app.get("/dashboard", include_in_schema=False)
async def serve_dashboard_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
@app.get("/login", include_in_schema=False)
async def serve_login_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "login" / "index.html")
# Add explicit route for /login/index.txt to redirect to /login
@app.get("/login/index.txt", include_in_schema=False)
async def redirect_login_index_txt() -> RedirectResponse:
return RedirectResponse("/login")
@app.get("/model", include_in_schema=False)
async def serve_models_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "model" / "index.html")
# Add explicit route for /model/index.txt to redirect to /model
@app.get("/model/index.txt", include_in_schema=False)
async def redirect_model_index_txt() -> RedirectResponse:
return RedirectResponse("/model")
@app.get("/providers", include_in_schema=False)
async def serve_providers_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
# Add explicit route for /providers/index.txt to redirect to /providers
@app.get("/providers/index.txt", include_in_schema=False)
async def redirect_providers_index_txt() -> RedirectResponse:
return RedirectResponse("/providers")
@app.get("/settings", include_in_schema=False)
async def serve_settings_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
# Add explicit route for /settings/index.txt to redirect to /settings
@app.get("/settings/index.txt", include_in_schema=False)
async def redirect_settings_index_txt() -> RedirectResponse:
return RedirectResponse("/settings")
@app.get("/transactions", include_in_schema=False)
async def serve_transactions_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
# Add explicit route for /transactions/index.txt to redirect to /transactions
@app.get("/transactions/index.txt", include_in_schema=False)
async def redirect_transactions_index_txt() -> RedirectResponse:
return RedirectResponse("/transactions")
@app.get("/balances", include_in_schema=False)
async def serve_balances_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "balances" / "index.html")
# Add explicit route for /balances/index.txt to redirect to /balances
@app.get("/balances/index.txt", include_in_schema=False)
async def redirect_balances_index_txt() -> RedirectResponse:
return RedirectResponse("/balances")
@app.get("/logs", include_in_schema=False)
async def serve_logs_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "logs" / "index.html")
# Add explicit route for /logs/index.txt to redirect to /logs
@app.get("/logs/index.txt", include_in_schema=False)
async def redirect_logs_index_txt() -> RedirectResponse:
return RedirectResponse("/logs")
@app.get("/usage", include_in_schema=False)
async def serve_usage_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
# Add explicit route for /usage/index.txt to redirect to /usage
@app.get("/usage/index.txt", include_in_schema=False)
async def redirect_usage_index_txt() -> RedirectResponse:
return RedirectResponse("/usage")
@app.get("/unauthorized", include_in_schema=False)
async def serve_unauthorized_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
@app.get("/unauthorized/index.txt", include_in_schema=False)
async def redirect_unauthorized_index_txt() -> RedirectResponse:
return RedirectResponse("/unauthorized")
@app.get("/favicon.ico", include_in_schema=False)
async def serve_favicon() -> FileResponse:
icon_path = UI_DIST_PATH / "icon.ico"
if icon_path.exists():
return FileResponse(icon_path)
return FileResponse(UI_DIST_PATH / "favicon.ico")
@app.get("/icon.ico", include_in_schema=False)
async def serve_icon() -> FileResponse:
return FileResponse(UI_DIST_PATH / "icon.ico")
app.mount(
"/static", StaticFiles(directory=UI_DIST_PATH, check_dir=True), name="ui-static"
)
else:
logger.warning(
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
)
@app.get("/", include_in_schema=False)
async def root_fallback() -> dict:
return {
"name": global_settings.name,
"description": global_settings.description,
"version": __version__,
"status": "running",
"ui": "not available",
}
setup_ui_routes(app)
app.include_router(models_router)
app.include_router(admin_router)
+127
View File
@@ -0,0 +1,127 @@
import asyncio
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI
from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_cache_refresher
from ..nip91 import announce_provider
from ..payment.models import (
cleanup_enabled_models_periodically,
update_sats_pricing,
)
from ..payment.price import _update_prices, update_prices_periodically
from ..proxy import get_upstreams, initialize_upstreams, refresh_model_maps_periodically
from ..upstream.helpers import refresh_upstreams_models_periodically
from ..wallet import periodic_payout
from .db import create_session, init_db, run_migrations
from .logging import get_logger
from .settings import SettingsService
from .settings import settings as global_settings
logger = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
version = getattr(app, "version", "unknown")
logger.info("Application startup initiated", extra={"version": version})
btc_price_task: asyncio.Task | None = None
pricing_task: asyncio.Task | None = None
payout_task: asyncio.Task | None = None
nip91_task: asyncio.Task | None = None
providers_task: asyncio.Task | None = None
models_refresh_task: asyncio.Task | None = None
models_cleanup_task: asyncio.Task | None = None
model_maps_refresh_task: asyncio.Task | None = None
try:
logger.info("Running database migrations")
run_migrations()
await init_db()
async with create_session() as session:
s = await SettingsService.initialize(session)
try:
app.title = s.name
app.description = s.description
except Exception: # pragma: no cover - defensive
pass
await _update_prices()
await initialize_upstreams()
btc_price_task = asyncio.create_task(update_prices_periodically())
pricing_task = asyncio.create_task(update_sats_pricing())
if global_settings.models_refresh_interval_seconds > 0:
models_refresh_task = asyncio.create_task(
refresh_upstreams_models_periodically(get_upstreams())
)
models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically())
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
payout_task = asyncio.create_task(periodic_payout())
if global_settings.nsec:
nip91_task = asyncio.create_task(announce_provider())
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
yield
except Exception as e: # pragma: no cover - logged and re-raised
logger.error(
"Application startup failed",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise
finally:
logger.info("Application shutdown initiated")
if btc_price_task is not None:
btc_price_task.cancel()
if pricing_task is not None:
pricing_task.cancel()
if payout_task is not None:
payout_task.cancel()
if nip91_task is not None:
nip91_task.cancel()
if providers_task is not None:
providers_task.cancel()
if models_refresh_task is not None:
models_refresh_task.cancel()
if models_cleanup_task is not None:
models_cleanup_task.cancel()
if model_maps_refresh_task is not None:
model_maps_refresh_task.cancel()
try:
tasks_to_wait: list[asyncio.Task] = []
if btc_price_task is not None:
tasks_to_wait.append(btc_price_task)
if pricing_task is not None:
tasks_to_wait.append(pricing_task)
if payout_task is not None:
tasks_to_wait.append(payout_task)
if nip91_task is not None:
tasks_to_wait.append(nip91_task)
if providers_task is not None:
tasks_to_wait.append(providers_task)
if models_refresh_task is not None:
tasks_to_wait.append(models_refresh_task)
if models_cleanup_task is not None:
tasks_to_wait.append(models_cleanup_task)
if model_maps_refresh_task is not None:
tasks_to_wait.append(model_maps_refresh_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
logger.info("Background tasks stopped successfully")
except Exception as e: # pragma: no cover - defensive
logger.error(
"Error stopping background tasks",
extra={"error": str(e), "error_type": type(e).__name__},
)
+150
View File
@@ -0,0 +1,150 @@
from pathlib import Path
from fastapi import APIRouter, FastAPI
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from .logging import get_logger
from .settings import settings as global_settings
logger = get_logger(__name__)
def setup_ui_routes(app: FastAPI) -> None:
ui_dist_path = Path(__file__).parent.parent.parent / "ui_out"
if ui_dist_path.exists() and ui_dist_path.is_dir():
logger.info(f"Serving static UI from {ui_dist_path}")
app.mount(
"/_next",
StaticFiles(directory=ui_dist_path / "_next", check_dir=True),
name="next-static",
)
router = APIRouter()
@router.get("/", include_in_schema=False)
async def serve_root_ui() -> FileResponse:
return FileResponse(ui_dist_path / "index.html")
@router.get("/index.txt", include_in_schema=False)
async def redirect_index_txt() -> RedirectResponse:
return RedirectResponse("/")
@router.get("/admin")
async def admin_redirect() -> FileResponse:
return FileResponse(ui_dist_path / "index.html")
@router.get("/dashboard", include_in_schema=False)
async def serve_dashboard_ui() -> FileResponse:
return FileResponse(ui_dist_path / "index.html")
@router.get("/login", include_in_schema=False)
async def serve_login_ui() -> FileResponse:
return FileResponse(ui_dist_path / "login" / "index.html")
@router.get("/login/index.txt", include_in_schema=False)
async def redirect_login_index_txt() -> RedirectResponse:
return RedirectResponse("/login")
@router.get("/model", include_in_schema=False)
async def serve_models_ui() -> FileResponse:
return FileResponse(ui_dist_path / "model" / "index.html")
@router.get("/model/index.txt", include_in_schema=False)
async def redirect_model_index_txt() -> RedirectResponse:
return RedirectResponse("/model")
@router.get("/providers", include_in_schema=False)
async def serve_providers_ui() -> FileResponse:
return FileResponse(ui_dist_path / "providers" / "index.html")
@router.get("/providers/index.txt", include_in_schema=False)
async def redirect_providers_index_txt() -> RedirectResponse:
return RedirectResponse("/providers")
@router.get("/settings", include_in_schema=False)
async def serve_settings_ui() -> FileResponse:
return FileResponse(ui_dist_path / "settings" / "index.html")
@router.get("/settings/index.txt", include_in_schema=False)
async def redirect_settings_index_txt() -> RedirectResponse:
return RedirectResponse("/settings")
@router.get("/transactions", include_in_schema=False)
async def serve_transactions_ui() -> FileResponse:
return FileResponse(ui_dist_path / "transactions" / "index.html")
@router.get("/transactions/index.txt", include_in_schema=False)
async def redirect_transactions_index_txt() -> RedirectResponse:
return RedirectResponse("/transactions")
@router.get("/balances", include_in_schema=False)
async def serve_balances_ui() -> FileResponse:
return FileResponse(ui_dist_path / "balances" / "index.html")
@router.get("/balances/index.txt", include_in_schema=False)
async def redirect_balances_index_txt() -> RedirectResponse:
return RedirectResponse("/balances")
@router.get("/logs", include_in_schema=False)
async def serve_logs_ui() -> FileResponse:
return FileResponse(ui_dist_path / "logs" / "index.html")
@router.get("/logs/index.txt", include_in_schema=False)
async def redirect_logs_index_txt() -> RedirectResponse:
return RedirectResponse("/logs")
@router.get("/usage", include_in_schema=False)
async def serve_usage_ui() -> FileResponse:
return FileResponse(ui_dist_path / "usage" / "index.html")
@router.get("/usage/index.txt", include_in_schema=False)
async def redirect_usage_index_txt() -> RedirectResponse:
return RedirectResponse("/usage")
@router.get("/unauthorized", include_in_schema=False)
async def serve_unauthorized_ui() -> FileResponse:
return FileResponse(ui_dist_path / "unauthorized" / "index.html")
@router.get("/unauthorized/index.txt", include_in_schema=False)
async def redirect_unauthorized_index_txt() -> RedirectResponse:
return RedirectResponse("/unauthorized")
@router.get("/favicon.ico", include_in_schema=False)
async def serve_favicon() -> FileResponse:
icon_path = ui_dist_path / "icon.ico"
if icon_path.exists():
return FileResponse(icon_path)
return FileResponse(ui_dist_path / "favicon.ico")
@router.get("/icon.ico", include_in_schema=False)
async def serve_icon() -> FileResponse:
return FileResponse(ui_dist_path / "icon.ico")
app.include_router(router)
app.mount(
"/static",
StaticFiles(directory=ui_dist_path, check_dir=True),
name="ui-static",
)
else:
logger.warning(
f"UI dist directory not found at {ui_dist_path}, skipping static file serving",
)
router = APIRouter()
@router.get("/", include_in_schema=False)
async def root_fallback() -> dict:
return {
"name": global_settings.name,
"description": global_settings.description,
"version": app.version,
"status": "running",
"ui": "not available",
}
app.include_router(router)