2727```
2828"""
2929
30+ import functools
31+ import importlib
32+ import importlib .metadata
3033import logging
3134import pathlib
3235import sys
33- from importlib .metadata import version
36+ from collections .abc import Generator
37+ from contextlib import asynccontextmanager
3438from typing import Any
3539
3640import uvicorn
4044from fastapi .openapi .utils import get_openapi
4145from grpclib .client import Channel
4246from pydantic import BaseModel
43- from pyhocon import ConfigFactory
47+ from pyhocon import ConfigFactory , ConfigTree
4448from starlette .responses import FileResponse
4549from starlette .staticfiles import StaticFiles
4650
51+ from quartz_api .internal import models , service
4752from quartz_api .internal .backends import DataPlatformClient , DummyClient , QuartzClient
4853from quartz_api .internal .middleware import audit , auth
49- from quartz_api .internal .models import DatabaseInterface , get_db_client
50- from quartz_api .internal .service import regions , sites
5154
5255log = logging .getLogger (__name__ )
5356logging .basicConfig (level = logging .DEBUG , stream = sys .stdout )
@@ -59,6 +62,7 @@ class GetHealthResponse(BaseModel):
5962
6063 status : int
6164
65+
6266def _custom_openapi (server : FastAPI ) -> dict [str , Any ]:
6367 """Customize the OpenAPI schema for ReDoc."""
6468 if server .openapi_schema :
@@ -87,20 +91,51 @@ def _custom_openapi(server: FastAPI) -> dict[str, Any]:
8791 return openapi_schema
8892
8993
90- def run () -> None :
91- """Run the API using a uvicorn server."""
92- # Get the application configuration from the environment
93- conf = ConfigFactory .parse_file ((pathlib .Path (__file__ ).parent / "server.conf" ).as_posix ())
94+ @asynccontextmanager
95+ async def _lifespan (server : FastAPI , conf : ConfigTree ) -> Generator [None ]:
96+ """Configure FastAPI app instance with startup and shutdown events."""
97+ db_instance : models .DatabaseInterface | None = None
98+ grpc_channel : Channel | None = None
9499
95- # Create the FastAPI server
100+ match conf .get_string ("backend.source" ):
101+ case "quartzdb" :
102+ db_instance = QuartzClient (
103+ database_url = conf .get_string ("backend.quartzdb.database_url" ),
104+ )
105+ case "dummydb" :
106+ db_instance = DummyClient ()
107+ log .warning ("disabled backend. NOT recommended for production" )
108+ case "dataplatform" :
109+ grpc_channel = Channel (
110+ host = conf .get_string ("backend.dataplatform.host" ),
111+ port = conf .get_int ("backend.dataplatform.port" ),
112+ )
113+ client = dp .DataPlatformDataServiceStub (channel = grpc_channel )
114+ db_instance = DataPlatformClient .from_dp (dp_client = client )
115+ case _ as backend_type :
116+ raise ValueError (f"Unknown backend: { backend_type } " )
117+
118+ server .dependency_overrides [models .get_db_client ] = lambda : db_instance
119+
120+ yield
121+
122+ if grpc_channel :
123+ grpc_channel .close ()
124+
125+
126+ def _create_server (conf : ConfigTree ) -> FastAPI :
127+ """Configure FastAPI app instance with routes, dependencies, and middleware."""
96128 server = FastAPI (
97- version = version ("quartz_api" ),
129+ version = importlib .metadata .version ("quartz_api" ),
130+ lifespan = functools .partial (_lifespan , conf = conf ),
98131 title = "Quartz API" ,
99132 description = __doc__ ,
100- openapi_tags = [{
101- "name" : "API Information" ,
102- "description" : "Routes providing information about the API." ,
103- }],
133+ openapi_tags = [
134+ {
135+ "name" : "API Information" ,
136+ "description" : "Routes providing information about the API." ,
137+ },
138+ ],
104139 docs_url = "/swagger" ,
105140 redoc_url = None ,
106141 )
@@ -126,14 +161,33 @@ def redoc_html() -> FileResponse:
126161 # Setup sentry, if configured
127162 if conf .get_string ("sentry.dsn" ) != "" :
128163 import sentry_sdk
164+
129165 sentry_sdk .init (
130166 dsn = conf .get_string ("sentry.dsn" ),
131167 environment = conf .get_string ("sentry.environment" ),
132168 traces_sample_rate = 1 ,
133169 )
134170
135- sentry_sdk .set_tag ("app_name" , "quartz_api" )
136- sentry_sdk .set_tag ("version" , version ("quartz_api" ))
171+ sentry_sdk .set_tag ("server_name" , "quartz_api" )
172+ sentry_sdk .set_tag ("version" , importlib .metadata .version ("quartz_api" ))
173+
174+ # Add routers to the server according to configuration
175+ for r in conf .get_string ("api.routers" ).split ("," ):
176+ try :
177+ mod = importlib .import_module (service .__name__ + f".{ r } " )
178+ server .include_router (mod .router )
179+ except ModuleNotFoundError as e :
180+ raise OSError (f"No such router router '{ r } '" ) from e
181+ server .openapi_tags = [
182+ {
183+ "name" : mod .__name__ .split ("." )[- 1 ].capitalize (),
184+ "description" : mod .__doc__ ,
185+ },
186+ * server .openapi_tags ,
187+ ]
188+
189+ # Customize the OpenAPI schema
190+ server .openapi = lambda : _custom_openapi (server )
137191
138192 # Override dependencies according to configuration
139193 match (conf .get_string ("auth0.domain" ), conf .get_string ("auth0.audience" )):
@@ -149,30 +203,8 @@ def redoc_html() -> FileResponse:
149203 case _:
150204 raise ValueError ("Invalid Auth0 configuration" )
151205
152- db_instance : DatabaseInterface
153- match conf .get_string ("backend.source" ):
154- case "quartzdb" :
155- db_instance = QuartzClient (
156- database_url = conf .get_string ("backend.quartzdb.database_url" ),
157- )
158- case "dummydb" :
159- db_instance = DummyClient ()
160- log .warning ("disabled backend. NOT recommended for production" )
161- case "dataplatform" :
162-
163- channel = Channel (
164- host = conf .get_string ("backend.dataplatform.host" ),
165- port = conf .get_int ("backend.dataplatform.port" ),
166- )
167- client = dp .DataPlatformDataServiceStub (channel = channel )
168- db_instance = DataPlatformClient .from_dp (dp_client = client )
169- case _:
170- raise ValueError (
171- "Unknown backend. "
172- f"Expected one of { list (conf .get ('backend' ).keys ())} " ,
173- )
174-
175- server .dependency_overrides [get_db_client ] = lambda : db_instance
206+ timezone : str = conf .get_string ("api.timezone" )
207+ server .dependency_overrides [models .get_timezone ] = lambda : timezone
176208
177209 # Add middlewares
178210 server .add_middleware (
@@ -182,32 +214,27 @@ def redoc_html() -> FileResponse:
182214 allow_methods = ["*" ],
183215 allow_headers = ["*" ],
184216 )
185- server .add_middleware (
186- audit .RequestLoggerMiddleware ,
187- db_client = db_instance ,
188- )
217+ server .add_middleware (audit .RequestLoggerMiddleware )
189218
190- # Add routers to the server according to configuration
191- for router_module in [sites , regions ]:
192- if conf .get_string ("api.router" ) == router_module .__name__ .split ("." )[- 1 ]:
193- server .include_router (router_module .router )
194- server .openapi_tags = [{
195- "name" : router_module .__name__ .split ("." )[- 1 ].capitalize (),
196- "description" : router_module .__doc__ ,
197- }, * server .openapi_tags ]
198- break
219+ return server
199220
200- # Customize the OpenAPI schema
201- server .openapi = lambda : _custom_openapi (server )
221+
222+ def run () -> None :
223+ """Run the API using a uvicorn server."""
224+ # Get the application configuration from the environment
225+ conf = ConfigFactory .parse_file ((pathlib .Path (__file__ ).parent / "server.conf" ).as_posix ())
226+
227+ server = _create_server (conf = conf )
202228
203229 # Run the server with uvicorn
204230 uvicorn .run (
205231 server ,
206- host = "0.0.0.0" , # noqa: S104
232+ host = "0.0.0.0" , # noqa: S104
207233 port = conf .get_int ("api.port" ),
208234 log_level = conf .get_string ("api.loglevel" ),
209235 )
210236
211237
212238if __name__ == "__main__" :
213239 run ()
240+
0 commit comments