mirror of
https://github.com/nihilvux/bancho.py.git
synced 2025-09-17 02:58:39 -07:00
197 lines
6.2 KiB
Python
197 lines
6.2 KiB
Python
# #!/usr/bin/env python3.11
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import io
|
|
import pprint
|
|
import sys
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any
|
|
|
|
import starlette.routing
|
|
from fastapi import FastAPI
|
|
from fastapi import status
|
|
from fastapi.encoders import jsonable_encoder
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.openapi.utils import get_openapi
|
|
from fastapi.requests import Request
|
|
from fastapi.responses import ORJSONResponse
|
|
from fastapi.responses import Response
|
|
from starlette.middleware.base import RequestResponseEndpoint
|
|
from starlette.requests import ClientDisconnect
|
|
|
|
import app.bg_loops
|
|
import app.settings
|
|
import app.state
|
|
import app.utils
|
|
from app.api import api_router # type: ignore[attr-defined]
|
|
from app.api import domains
|
|
from app.api import middlewares
|
|
from app.logging import Ansi
|
|
from app.logging import log
|
|
from app.objects import collections
|
|
|
|
|
|
class BanchoAPI(FastAPI):
|
|
def openapi(self) -> dict[str, Any]:
|
|
if not self.openapi_schema:
|
|
routes = self.routes
|
|
starlette_hosts = [
|
|
host
|
|
for host in super().routes
|
|
if isinstance(host, starlette.routing.Host)
|
|
]
|
|
|
|
# XXX:HACK fastapi will not show documentation for routes
|
|
# added through use sub applications using the Host class
|
|
# (e.g. app.host('other.domain', app2))
|
|
for host in starlette_hosts:
|
|
for route in host.routes:
|
|
if route not in routes:
|
|
routes.append(route)
|
|
|
|
self.openapi_schema = get_openapi(
|
|
title=self.title,
|
|
version=self.version,
|
|
openapi_version=self.openapi_version,
|
|
description=self.description,
|
|
terms_of_service=self.terms_of_service,
|
|
contact=self.contact,
|
|
license_info=self.license_info,
|
|
routes=routes,
|
|
tags=self.openapi_tags,
|
|
servers=self.servers,
|
|
)
|
|
|
|
return self.openapi_schema
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(asgi_app: BanchoAPI) -> AsyncIterator[None]:
|
|
if isinstance(sys.stdout, io.TextIOWrapper):
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
|
|
app.utils.ensure_persistent_volumes_are_available()
|
|
|
|
app.state.loop = asyncio.get_running_loop()
|
|
|
|
if app.utils.is_running_as_admin():
|
|
log(
|
|
"Running the server with root privileges is not recommended.",
|
|
Ansi.LYELLOW,
|
|
)
|
|
|
|
await app.state.services.database.connect()
|
|
await app.state.services.redis.initialize()
|
|
|
|
if app.state.services.datadog is not None:
|
|
app.state.services.datadog.start(
|
|
flush_in_thread=True,
|
|
flush_interval=15,
|
|
)
|
|
app.state.services.datadog.gauge("bancho.online_players", 0)
|
|
|
|
app.state.services.ip_resolver = app.state.services.IPResolver()
|
|
|
|
await app.state.services.run_sql_migrations()
|
|
|
|
await collections.initialize_ram_caches()
|
|
|
|
await app.bg_loops.initialize_housekeeping_tasks()
|
|
|
|
log("Startup process complete.", Ansi.LGREEN)
|
|
log(
|
|
f"Listening @ {app.settings.APP_HOST}:{app.settings.APP_PORT}",
|
|
Ansi.LMAGENTA,
|
|
)
|
|
|
|
yield
|
|
|
|
# we want to attempt to gracefully finish any ongoing connections
|
|
# and shut down any of the housekeeping tasks running in the background.
|
|
await app.state.sessions.cancel_housekeeping_tasks()
|
|
|
|
# shutdown services
|
|
|
|
await app.state.services.http_client.aclose()
|
|
await app.state.services.database.disconnect()
|
|
await app.state.services.redis.aclose()
|
|
|
|
if app.state.services.datadog is not None:
|
|
app.state.services.datadog.stop()
|
|
app.state.services.datadog.flush()
|
|
|
|
|
|
def init_exception_handlers(asgi_app: BanchoAPI) -> None:
|
|
@asgi_app.exception_handler(RequestValidationError)
|
|
async def handle_validation_error(
|
|
request: Request,
|
|
exc: RequestValidationError,
|
|
) -> Response:
|
|
"""Wrapper around 422 validation errors to print out info for devs."""
|
|
log(f"Validation error on {request.url}", Ansi.LRED)
|
|
pprint.pprint(exc.errors())
|
|
|
|
return ORJSONResponse(
|
|
content={"detail": jsonable_encoder(exc.errors())},
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
)
|
|
|
|
|
|
def init_middlewares(asgi_app: BanchoAPI) -> None:
|
|
"""Initialize our app's middleware stack."""
|
|
asgi_app.add_middleware(middlewares.MetricsMiddleware)
|
|
|
|
@asgi_app.middleware("http")
|
|
async def http_middleware(
|
|
request: Request,
|
|
call_next: RequestResponseEndpoint,
|
|
) -> Response:
|
|
# if an osu! client is waiting on leaderboard data
|
|
# and switches to another leaderboard, it will cancel
|
|
# the previous request midway, resulting in a large
|
|
# error in the console. this is to catch that :)
|
|
|
|
try:
|
|
return await call_next(request)
|
|
except ClientDisconnect:
|
|
# client disconnected from the server
|
|
# while we were reading the body.
|
|
return Response("Client disconnected while reading request.")
|
|
except RuntimeError as exc:
|
|
if exc.args[0] == "No response returned.":
|
|
# client disconnected from the server
|
|
# while we were sending the response.
|
|
return Response("Client returned empty response.")
|
|
|
|
# unrelated issue, raise normally
|
|
raise exc
|
|
|
|
|
|
def init_routes(asgi_app: BanchoAPI) -> None:
|
|
"""Initialize our app's route endpoints."""
|
|
for domain in ("ppy.sh", app.settings.DOMAIN):
|
|
for subdomain in ("c", "ce", "c4", "c5", "c6"):
|
|
asgi_app.host(f"{subdomain}.{domain}", domains.cho.router)
|
|
|
|
asgi_app.host(f"osu.{domain}", domains.osu.router)
|
|
asgi_app.host(f"b.{domain}", domains.map.router)
|
|
|
|
# bancho.py's developer-facing api
|
|
asgi_app.host(f"api.{domain}", api_router)
|
|
|
|
|
|
def init_api() -> BanchoAPI:
|
|
"""Create & initialize our app."""
|
|
asgi_app = BanchoAPI(lifespan=lifespan)
|
|
|
|
init_middlewares(asgi_app)
|
|
init_exception_handlers(asgi_app)
|
|
init_routes(asgi_app)
|
|
|
|
return asgi_app
|
|
|
|
|
|
asgi_app = init_api()
|